From 16e3bb935b3b2872773ad7090acb228aca327efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Mon, 2 Oct 2023 07:43:52 +0200 Subject: [PATCH] feat: add local-dns command (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Makes it possible to install local DNS routing for linkup. Uses of a combination of [caddy](https://caddyserver.com/) and [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) to achieve local TLS/SSL and DNS for this to work in a frictionless way. --------- Co-authored-by: Oliver Stenbom Co-authored-by: Augusto César --- linkup-cli/src/background_tunnel.rs | 7 +- linkup-cli/src/local_config.rs | 75 ++++++++++- linkup-cli/src/local_dns.rs | 196 ++++++++++++++++++++++++++++ linkup-cli/src/local_server.rs | 21 ++- linkup-cli/src/main.rs | 67 ++++++++-- linkup-cli/src/reset.rs | 21 ++- linkup-cli/src/services/caddy.rs | 100 ++++++++++++++ linkup-cli/src/services/dnsmasq.rs | 69 ++++++++++ linkup-cli/src/services/mod.rs | 2 + linkup-cli/src/signal.rs | 17 ++- linkup-cli/src/start.rs | 66 +++------- linkup-cli/src/status.rs | 6 +- linkup-cli/src/stop.rs | 73 ++++++----- linkup/src/lib.rs | 15 ++- linkup/src/session_allocator.rs | 7 + worker/src/http_util.rs | 2 +- 16 files changed, 621 insertions(+), 123 deletions(-) create mode 100644 linkup-cli/src/local_dns.rs create mode 100644 linkup-cli/src/services/caddy.rs create mode 100644 linkup-cli/src/services/dnsmasq.rs create mode 100644 linkup-cli/src/services/mod.rs diff --git a/linkup-cli/src/background_tunnel.rs b/linkup-cli/src/background_tunnel.rs index 4eccc99..ee27b58 100644 --- a/linkup-cli/src/background_tunnel.rs +++ b/linkup-cli/src/background_tunnel.rs @@ -6,10 +6,11 @@ use std::thread; use std::time::Duration; use daemonize::{Daemonize, Outcome}; +use nix::sys::signal::Signal; use regex::Regex; use url::Url; -use crate::signal::send_sigint; +use crate::signal::send_signal; use crate::stop::stop_pid_file; use crate::{linkup_file_path, CliError}; @@ -133,7 +134,7 @@ fn try_start_tunnel() -> Result { match rx.recv_timeout(Duration::from_secs(TUNNEL_START_WAIT)) { Ok(result) => result, Err(e) => { - stop_pid_file(LINKUP_CLOUDFLARED_PID)?; + stop_pid_file(&linkup_file_path(LINKUP_CLOUDFLARED_PID), Signal::SIGINT)?; Err(CliError::StartLocalTunnel(format!( "Failed to obtain tunnel URL within {} seconds: {}", TUNNEL_START_WAIT, e @@ -161,7 +162,7 @@ fn daemonized_tunnel_child() { ONCE.call_once(|| { ctrlc::set_handler(move || { println!("Killing child process {}", pid); - let kill_res = send_sigint(pid.to_string().as_str()); + let kill_res = send_signal(pid.to_string().as_str(), Signal::SIGINT); println!("Kill result: {:?}", kill_res); let _ = remove_file(linkup_file_path(LINKUP_CLOUDFLARED_PID)); diff --git a/linkup-cli/src/local_config.rs b/linkup-cli/src/local_config.rs index 25e7eda..e9481b3 100644 --- a/linkup-cli/src/local_config.rs +++ b/linkup-cli/src/local_config.rs @@ -1,4 +1,8 @@ -use std::fmt::{self, Display, Formatter}; +use std::{ + env, + fmt::{self, Display, Formatter}, + fs, +}; use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; @@ -6,6 +10,8 @@ use url::Url; use linkup::{StorableDomain, StorableRewrite}; +use crate::{CliError, LINKUP_CONFIG_ENV}; + #[derive(Deserialize, Serialize, Clone)] pub struct LocalState { pub linkup: LinkupState, @@ -48,20 +54,35 @@ impl Display for ServiceTarget { } } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] pub struct YamlLocalConfig { linkup: LinkupConfig, services: Vec, domains: Vec, } -#[derive(Deserialize)] +impl YamlLocalConfig { + pub fn top_level_domains(&self) -> Vec { + self.domains + .iter() + .filter(|&d| { + !self + .domains + .iter() + .any(|other| other.domain != d.domain && d.domain.ends_with(&other.domain)) + }) + .map(|d| d.domain.clone()) + .collect::>() + } +} + +#[derive(Deserialize, Clone)] struct LinkupConfig { remote: Url, cache_routes: Option>, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] struct YamlLocalService { name: String, remote: Url, @@ -115,6 +136,52 @@ pub fn config_to_state(yaml_config: YamlLocalConfig, config_path: String) -> Loc } } +pub fn config_path(config_arg: &Option) -> Result { + match config_arg { + Some(path) => { + let absolute_path = fs::canonicalize(path) + .map_err(|_| CliError::NoConfig("Unable to resolve absolute path".to_string()))?; + Ok(absolute_path.to_string_lossy().into_owned()) + } + None => match env::var(LINKUP_CONFIG_ENV) { + Ok(val) => { + let absolute_path = fs::canonicalize(val).map_err(|_| { + CliError::NoConfig("Unable to resolve absolute path".to_string()) + })?; + Ok(absolute_path.to_string_lossy().into_owned()) + } + Err(_) => Err(CliError::NoConfig( + "No config argument provided and LINKUP_CONFIG environment variable not set" + .to_string(), + )), + }, + } +} + +pub fn get_config(config_path: &str) -> Result { + let content = match fs::read_to_string(config_path) { + Ok(content) => content, + Err(_) => { + return Err(CliError::BadConfig(format!( + "Failed to read the config file at {}", + config_path + ))) + } + }; + + let yaml_config: YamlLocalConfig = match serde_yaml::from_str(&content) { + Ok(config) => config, + Err(_) => { + return Err(CliError::BadConfig(format!( + "Failed to deserialize the config file at {}", + config_path + ))) + } + }; + + Ok(yaml_config) +} + #[cfg(test)] mod tests { use super::*; diff --git a/linkup-cli/src/local_dns.rs b/linkup-cli/src/local_dns.rs new file mode 100644 index 0000000..09b800e --- /dev/null +++ b/linkup-cli/src/local_dns.rs @@ -0,0 +1,196 @@ +use std::{ + fs, + process::{Command, Stdio}, +}; + +use crate::{ + linkup_file_path, + local_config::{config_path, get_config}, + services, CliError, Result, LINKUP_CF_TLS_API_ENV_VAR, LINKUP_LOCALDNS_INSTALL, +}; + +pub fn install(config_arg: &Option) -> Result<()> { + if std::env::var(LINKUP_CF_TLS_API_ENV_VAR).is_err() { + println!("local-dns uses Cloudflare to enable https through local certificates."); + println!( + "To use it, you need to set the {} environment variable.", + LINKUP_CF_TLS_API_ENV_VAR + ); + return Err(CliError::LocalDNSInstall(format!( + "{} env var is not set", + LINKUP_CF_TLS_API_ENV_VAR + ))); + } + + let config_path = config_path(config_arg)?; + let input_config = get_config(&config_path)?; + + if !is_sudo() { + println!("Linkup needs sudo access to:"); + println!(" - Ensure there is a folder /etc/resolvers"); + println!(" - Create file(s) for /etc/resolver/"); + println!(" - Flush DNS cache"); + } + + ensure_resolver_dir()?; + install_resolvers(&input_config.top_level_domains())?; + services::caddy::install_cloudflare_package()?; + + if fs::write(linkup_file_path(LINKUP_LOCALDNS_INSTALL), "").is_err() { + return Err(CliError::LocalDNSInstall(format!( + "Failed to write install localdns file at {}", + linkup_file_path(LINKUP_LOCALDNS_INSTALL).display() + ))); + } + + Ok(()) +} + +pub fn uninstall(config_arg: &Option) -> Result<()> { + let install_check_file = linkup_file_path(LINKUP_LOCALDNS_INSTALL); + if !install_check_file.exists() { + println!("Linkup local-dns is not installed"); + return Ok(()); + } + + let config_path = config_path(config_arg)?; + let input_config = get_config(&config_path)?; + + if !is_sudo() { + println!("Linkup needs sudo access to:"); + println!(" - Delete file(s) on /etc/resolver"); + println!(" - Flush DNS cache"); + } + + uninstall_resolvers(&input_config.top_level_domains())?; + + if let Err(err) = fs::remove_file(install_check_file) { + return Err(CliError::LocalDNSUninstall(format!( + "Failed to delete localdns file at {}. Reason: {}", + linkup_file_path(LINKUP_LOCALDNS_INSTALL).display(), + err + ))); + } + + Ok(()) +} + +fn ensure_resolver_dir() -> Result<()> { + Command::new("sudo") + .args(["mkdir", "/etc/resolver"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| { + CliError::LocalDNSInstall(format!( + "failed to create /etc/resolver folder. Reason: {}", + err + )) + })?; + + Ok(()) +} + +fn install_resolvers(resolve_domains: &[String]) -> Result<()> { + for domain in resolve_domains.iter() { + let cmd_str = format!( + "echo \"nameserver 127.0.0.1\nport 8053\" > /etc/resolver/{}", + domain + ); + let status = Command::new("sudo") + .arg("bash") + .arg("-c") + .arg(&cmd_str) + .status() + .map_err(|err| { + CliError::LocalDNSInstall(format!( + "Failed to install resolver for domain {} to /etc/resolver/{}. Reason: {}", + domain, domain, err + )) + })?; + + if !status.success() { + return Err(CliError::LocalDNSInstall(format!( + "Failed to install resolver for domain {} to /etc/resolver/{}", + domain, domain + ))); + } + } + + flush_dns_cache()?; + kill_dns_responder()?; + + Ok(()) +} + +fn uninstall_resolvers(resolve_domains: &[String]) -> Result<()> { + for domain in resolve_domains.iter() { + let folder = format!("/etc/resolver/{}", domain); + Command::new("sudo") + .args(["rm", "-rf", &folder]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| { + CliError::LocalDNSUninstall(format!( + "Failed to delete /etc/resolver/{}. Reason: {}", + domain, err + )) + })?; + } + + flush_dns_cache()?; + kill_dns_responder()?; + + Ok(()) +} + +fn flush_dns_cache() -> Result<()> { + let status_flush = Command::new("sudo") + .args(["dscacheutil", "-flushcache"]) + .status() + .map_err(|_err| { + CliError::LocalDNSInstall("Failed to run dscacheutil -flushcache".into()) + })?; + + if !status_flush.success() { + return Err(CliError::LocalDNSInstall( + "Failed to run dscacheutil -flushcache".into(), + )); + } + + Ok(()) +} + +fn kill_dns_responder() -> Result<()> { + let status_kill_responder = Command::new("sudo") + .args(["killall", "-HUP", "mDNSResponder"]) + .status() + .map_err(|_err| { + CliError::LocalDNSInstall("Failed to run killall -HUP mDNSResponder".into()) + })?; + + if !status_kill_responder.success() { + return Err(CliError::LocalDNSInstall( + "Failed to run killall -HUP mDNSResponder".into(), + )); + } + + Ok(()) +} + +fn is_sudo() -> bool { + let sudo_check = Command::new("sudo") + .arg("-n") + .arg("true") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + if let Ok(exit_status) = sudo_check { + return exit_status.success(); + } + + false +} diff --git a/linkup-cli/src/local_server.rs b/linkup-cli/src/local_server.rs index 8a0c734..635c05f 100644 --- a/linkup-cli/src/local_server.rs +++ b/linkup-cli/src/local_server.rs @@ -7,6 +7,7 @@ use futures::stream::StreamExt; use thiserror::Error; use linkup::*; +use url::Url; use crate::LINKUP_LOCALSERVER_PORT; @@ -59,7 +60,7 @@ async fn linkup_ws_request_handler( ) -> impl Responder { let sessions = SessionAllocator::new(string_store.into_inner()); - let url = format!("http://localhost:9066{}", req.uri()); + let url = format!("http://localhost:{}{}", LINKUP_LOCALSERVER_PORT, req.uri()); let headers = req .headers() .iter() @@ -167,7 +168,7 @@ async fn linkup_request_handler( ) -> impl Responder { let sessions = SessionAllocator::new(string_store.into_inner()); - let url = format!("http://localhost:9066{}", req.uri()); + let url = format!("http://localhost:{}{}", LINKUP_LOCALSERVER_PORT, req.uri()); let headers = req .headers() .iter() @@ -201,7 +202,18 @@ async fn linkup_request_handler( } }; - let extra_headers = get_additional_headers(url, &headers, &session_name, &dest_service_name); + let mut extra_headers = + get_additional_headers(url, &headers, &session_name, &dest_service_name); + + // TODO(ostenbom): Consider moving host override into additional_headers function + extra_headers.insert( + "host".to_string(), + Url::parse(&destination_url) + .unwrap() + .host_str() + .unwrap() + .to_string(), + ); // Proxy the request using the destination_url and the merged headers let client = reqwest::Client::new(); @@ -238,10 +250,11 @@ fn merge_headers( extra_headers: &HashMap, ) -> reqwest::header::HeaderMap { let mut header_map = reqwest::header::HeaderMap::new(); + // Give the extra headers precedence for (key, value) in original_headers.iter().chain(extra_headers.iter()) { if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) { if let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) { - header_map.append(header_name, header_value); + header_map.insert(header_name, header_value); } } } diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 8923ae8..04d4f06 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -9,9 +9,11 @@ mod background_local_server; mod background_tunnel; mod completion; mod local_config; +mod local_dns; mod local_server; mod remote_local; mod reset; +mod services; mod signal; mod start; mod status; @@ -31,8 +33,10 @@ const LINKUP_STATE_FILE: &str = "state"; const LINKUP_LOCALSERVER_PID_FILE: &str = "localserver-pid"; const LINKUP_CLOUDFLARED_PID: &str = "cloudflared-pid"; const LINKUP_ENV_SEPARATOR: &str = "##### Linkup environment - DO NOT EDIT #####"; +const LINKUP_LOCALDNS_INSTALL: &str = "localdns-install"; +const LINKUP_CF_TLS_API_ENV_VAR: &str = "LINKUP_CF_API_TOKEN"; -pub fn linkup_file_path(file: &str) -> PathBuf { +pub fn linkup_dir_path() -> PathBuf { let storage_dir = match env::var("HOME") { Ok(val) => val, Err(_e) => "/var/tmp".to_string(), @@ -41,19 +45,17 @@ pub fn linkup_file_path(file: &str) -> PathBuf { let mut path = PathBuf::new(); path.push(storage_dir); path.push(LINKUP_DIR); - path.push(file); path } -fn ensure_linkup_dir() -> Result<(), CliError> { - let storage_dir = match env::var("HOME") { - Ok(val) => val, - Err(_e) => "/var/tmp".to_string(), - }; +pub fn linkup_file_path(file: &str) -> PathBuf { + let mut path = linkup_dir_path(); + path.push(file); + path +} - let mut path = PathBuf::new(); - path.push(storage_dir); - path.push(LINKUP_DIR); +fn ensure_linkup_dir() -> Result<()> { + let path = linkup_dir_path(); match fs::create_dir(&path) { Ok(_) => Ok(()), @@ -68,6 +70,8 @@ fn ensure_linkup_dir() -> Result<(), CliError> { } } +pub type Result = std::result::Result; + #[derive(Error, Debug)] pub enum CliError { #[error("no valid state file: {0}")] @@ -90,6 +94,8 @@ pub enum CliError { StartLocalTunnel(String), #[error("linkup component did not start in time: {0}")] StartLinkupTimeout(String), + #[error("could not start Caddy: {0}")] + StartCaddy(String), #[error("could not load config to {0}: {1}")] LoadConfig(String, String), #[error("could not stop: {0}")] @@ -100,6 +106,12 @@ pub enum CliError { InconsistentState, #[error("no such service: {0}")] NoSuchService(String), + #[error("failed to install local dns: {0}")] + LocalDNSInstall(String), + #[error("failed to uninstall local dns: {0}")] + LocalDNSUninstall(String), + #[error("failed to write file: {0}")] + WriteFile(String), } #[derive(Parser)] @@ -111,6 +123,11 @@ struct Cli { #[command(subcommand)] command: Commands, } +#[derive(Subcommand)] +enum LocalDNSSubcommand { + Install, + Uninstall, +} #[derive(Subcommand)] enum Commands { @@ -127,7 +144,15 @@ enum Commands { #[clap(about = "Stop a running linkup session")] Stop {}, #[clap(about = "Reset a linkup session")] - Reset {}, + Reset { + #[arg( + short, + long, + value_name = "CONFIG", + help = "Path to config file, overriding environment variable." + )] + config: Option, + }, #[clap(about = "Route session traffic to a local service")] Local { service_names: Vec }, #[clap(about = "Route session traffic to a remote service")] @@ -140,6 +165,18 @@ enum Commands { #[arg(short, long)] all: bool, }, + LocalDNS { + #[arg( + short, + long, + value_name = "CONFIG", + help = "Path to config file, overriding environment variable." + )] + config: Option, + + #[clap(subcommand)] + subcommand: LocalDNSSubcommand, + }, #[clap(about = "Generate completions for your shell")] Completion { #[arg(long, value_enum)] @@ -147,7 +184,7 @@ enum Commands { }, } -fn main() -> Result<(), CliError> { +fn main() -> Result<()> { let cli = Cli::parse(); ensure_linkup_dir()?; @@ -155,10 +192,14 @@ fn main() -> Result<(), CliError> { match &cli.command { Commands::Start { config } => start(config), Commands::Stop {} => stop(), - Commands::Reset {} => reset(), + Commands::Reset { config } => reset(config), Commands::Local { service_names } => local(service_names), Commands::Remote { service_names } => remote(service_names), Commands::Status { json, all } => status(*json, *all), + Commands::LocalDNS { config, subcommand } => match subcommand { + LocalDNSSubcommand::Install => local_dns::install(config), + LocalDNSSubcommand::Uninstall => local_dns::uninstall(config), + }, Commands::Completion { shell } => completion(shell), } } diff --git a/linkup-cli/src/reset.rs b/linkup-cli/src/reset.rs index 4b2d13a..7b63576 100644 --- a/linkup-cli/src/reset.rs +++ b/linkup-cli/src/reset.rs @@ -1,11 +1,26 @@ use crate::{ - background_booting::boot_background_services, start::get_state, stop::shutdown, CliError, + background_booting::boot_background_services, + linkup_file_path, + local_config::{config_path, get_config}, + start::{boot_local_dns, get_state}, + stop::shutdown, + CliError, LINKUP_LOCALDNS_INSTALL, }; -pub fn reset() -> Result<(), CliError> { +// TODO(ostenbom)[2023-09-26]: Config arg shouldn't be needed here, we could use config state for this +pub fn reset(config_arg: &Option) -> Result<(), CliError> { // Ensure there is some kind of state from before, otherwise reset doesn't make sense get_state()?; shutdown()?; - boot_background_services() + boot_background_services()?; + + if linkup_file_path(LINKUP_LOCALDNS_INSTALL).exists() { + let config_path = config_path(config_arg)?; + let input_config = get_config(&config_path)?; + + boot_local_dns(&input_config)?; + } + + Ok(()) } diff --git a/linkup-cli/src/services/caddy.rs b/linkup-cli/src/services/caddy.rs new file mode 100644 index 0000000..d5d8fd1 --- /dev/null +++ b/linkup-cli/src/services/caddy.rs @@ -0,0 +1,100 @@ +use std::{ + fs, + process::{Command, Stdio}, +}; + +use crate::{ + linkup_dir_path, linkup_file_path, local_config::YamlLocalConfig, CliError, Result, + LINKUP_CF_TLS_API_ENV_VAR, LINKUP_LOCALSERVER_PORT, +}; + +const CADDYFILE: &str = "Caddyfile"; +const PID_FILE: &str = "caddy-pid"; +const LOG_FILE: &str = "caddy-log"; + +pub fn start(local_config: &YamlLocalConfig) -> Result<()> { + if std::env::var(LINKUP_CF_TLS_API_ENV_VAR).is_err() { + return Err(CliError::StartCaddy(format!( + "{} env var is not set", + LINKUP_CF_TLS_API_ENV_VAR + ))); + } + + let domains: Vec = local_config + .top_level_domains() + .iter() + .map(|domain| format!("*.{}", domain)) + .collect(); + + write_caddyfile(&domains)?; + + Command::new("caddy") + .current_dir(linkup_dir_path()) + .arg("start") + .arg("--pidfile") + .arg(linkup_file_path(PID_FILE)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| CliError::StartCaddy(err.to_string()))?; + + Ok(()) +} + +pub fn stop() -> Result<()> { + Command::new("caddy") + .current_dir(linkup_dir_path()) + .arg("stop") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| CliError::StopErr(err.to_string()))?; + + Ok(()) +} + +pub fn install_cloudflare_package() -> Result<()> { + Command::new("caddy") + .args(["add-package", "github.com/caddy-dns/cloudflare"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| CliError::StartCaddy(err.to_string()))?; + + Ok(()) +} + +fn write_caddyfile(domains: &[String]) -> Result<()> { + let caddy_template = format!( + " + {{ + http_port 80 + https_port 443 + log {{ + output file {} + }} + }} + + {} {{ + reverse_proxy localhost:{} + tls {{ + dns cloudflare {{env.{}}} + }} + }} + ", + linkup_file_path(LOG_FILE).display(), + domains.join(", "), + LINKUP_LOCALSERVER_PORT, + LINKUP_CF_TLS_API_ENV_VAR, + ); + + let caddyfile_path = linkup_file_path(CADDYFILE); + if fs::write(&caddyfile_path, caddy_template).is_err() { + return Err(CliError::WriteFile(format!( + "Failed to write Caddyfile at {}", + caddyfile_path.display(), + ))); + } + + Ok(()) +} diff --git a/linkup-cli/src/services/dnsmasq.rs b/linkup-cli/src/services/dnsmasq.rs new file mode 100644 index 0000000..52e317b --- /dev/null +++ b/linkup-cli/src/services/dnsmasq.rs @@ -0,0 +1,69 @@ +use std::{ + fs, + path::Path, + process::{Command, Stdio}, +}; + +use nix::sys::signal::Signal; + +use crate::{linkup_dir_path, linkup_file_path, stop::stop_pid_file, CliError, Result}; + +const PORT: u16 = 8053; +const CONF_FILE: &str = "dnsmasq-conf"; +const LOG_FILE: &str = "dnsmasq-log"; +const PID_FILE: &str = "dnsmasq-pid"; + +pub fn start() -> Result<()> { + let conf_file_path = linkup_file_path(CONF_FILE); + let logfile_path = linkup_file_path(LOG_FILE); + let pidfile_path = linkup_file_path(PID_FILE); + + if fs::write(&logfile_path, "").is_err() { + return Err(CliError::WriteFile(format!( + "Failed to write dnsmasq log file at {}", + logfile_path.display() + ))); + } + + write_conf_file(&conf_file_path, &logfile_path, &pidfile_path)?; + + Command::new("dnsmasq") + .current_dir(linkup_dir_path()) + .arg("--log-queries") + .arg("-C") + .arg(conf_file_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| CliError::StartCaddy(err.to_string()))?; + + Ok(()) +} + +// TODO(augustoccesar)[2023-09-26]: Do we really want to swallow these errors? +pub fn stop() { + let _ = stop_pid_file(&linkup_file_path(PID_FILE), Signal::SIGTERM); +} + +fn write_conf_file(conf_file_path: &Path, logfile_path: &Path, pidfile_path: &Path) -> Result<()> { + let dnsmasq_template = format!( + " + address=/#/127.0.0.1 + port={} + log-facility={} + pid-file={} + ", + PORT, + logfile_path.display(), + pidfile_path.display(), + ); + + if fs::write(conf_file_path, dnsmasq_template).is_err() { + return Err(CliError::WriteFile(format!( + "Failed to write dnsmasq config at {}", + conf_file_path.display() + ))); + } + + Ok(()) +} diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs new file mode 100644 index 0000000..2dc38ad --- /dev/null +++ b/linkup-cli/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod caddy; +pub mod dnsmasq; diff --git a/linkup-cli/src/signal.rs b/linkup-cli/src/signal.rs index 9ce2dd7..7051693 100644 --- a/linkup-cli/src/signal.rs +++ b/linkup-cli/src/signal.rs @@ -1,5 +1,7 @@ use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; +use std::fs::{self, File}; +use std::path::Path; use std::str::FromStr; use thiserror::Error; @@ -15,16 +17,27 @@ pub enum PidError { NoSuchProcess(String), } -pub fn send_sigint(pid_str: &str) -> Result<(), PidError> { +pub fn send_signal(pid_str: &str, signal: Signal) -> Result<(), PidError> { // Parse the PID string to a i32 let pid_num = i32::from_str(pid_str).map_err(|e| PidError::BadPidFile(e.to_string()))?; // Create a Pid from the i32 let pid = Pid::from_raw(pid_num); - match kill(pid, Some(Signal::SIGINT)) { + match kill(pid, Some(signal)) { Ok(_) => Ok(()), Err(nix::Error::ESRCH) => Err(PidError::NoSuchProcess(pid_str.to_string())), Err(e) => Err(PidError::SignalErr(e.to_string())), } } + +pub fn get_pid(file_path: &Path) -> Result { + if let Err(e) = File::open(file_path) { + return Err(PidError::NoPidFile(e.to_string())); + } + + match fs::read_to_string(file_path) { + Ok(content) => Ok(content.trim().to_string()), + Err(e) => Err(PidError::BadPidFile(e.to_string())), + } +} diff --git a/linkup-cli/src/start.rs b/linkup-cli/src/start.rs index 18b8672..bddb633 100644 --- a/linkup-cli/src/start.rs +++ b/linkup-cli/src/start.rs @@ -1,24 +1,25 @@ use std::io::Write; use std::{ - env, fs::{self, File, OpenOptions}, path::{Path, PathBuf}, }; +use crate::local_config::{config_path, get_config}; use crate::{ background_booting::boot_background_services, linkup_file_path, local_config::{config_to_state, LocalState, YamlLocalConfig}, status::{server_status, ServerStatus}, - CliError, LINKUP_CONFIG_ENV, LINKUP_ENV_SEPARATOR, LINKUP_STATE_FILE, + CliError, LINKUP_ENV_SEPARATOR, LINKUP_STATE_FILE, }; +use crate::{services, LINKUP_LOCALDNS_INSTALL}; pub fn start(config_arg: &Option) -> Result<(), CliError> { let previous_state = get_state(); let config_path = config_path(config_arg)?; - let input_config = get_config(config_path.clone())?; + let input_config = get_config(&config_path)?; - let mut state = config_to_state(input_config, config_path); + let mut state = config_to_state(input_config.clone(), config_path); // Reuse previous session name if possible if let Ok(ps) = previous_state { @@ -41,55 +42,13 @@ pub fn start(config_arg: &Option) -> Result<(), CliError> { boot_background_services()?; - check_local_not_started()?; - - Ok(()) -} - -fn config_path(config_arg: &Option) -> Result { - match config_arg { - Some(path) => { - let absolute_path = fs::canonicalize(path) - .map_err(|_| CliError::NoConfig("Unable to resolve absolute path".to_string()))?; - Ok(absolute_path.to_string_lossy().into_owned()) - } - None => match env::var(LINKUP_CONFIG_ENV) { - Ok(val) => { - let absolute_path = fs::canonicalize(val).map_err(|_| { - CliError::NoConfig("Unable to resolve absolute path".to_string()) - })?; - Ok(absolute_path.to_string_lossy().into_owned()) - } - Err(_) => Err(CliError::NoConfig( - "No config argument provided and LINKUP_CONFIG environment variable not set" - .to_string(), - )), - }, + if linkup_file_path(LINKUP_LOCALDNS_INSTALL).exists() { + boot_local_dns(&input_config)?; } -} - -fn get_config(config_path: String) -> Result { - let content = match fs::read_to_string(&config_path) { - Ok(content) => content, - Err(_) => { - return Err(CliError::BadConfig(format!( - "Failed to read the config file at {}", - config_path - ))) - } - }; - let yaml_config: YamlLocalConfig = match serde_yaml::from_str(&content) { - Ok(config) => config, - Err(_) => { - return Err(CliError::BadConfig(format!( - "Failed to deserialize the config file at {}", - config_path - ))) - } - }; + check_local_not_started()?; - Ok(yaml_config) + Ok(()) } pub fn get_state() -> Result { @@ -225,3 +184,10 @@ fn check_local_not_started() -> Result<(), CliError> { } Ok(()) } + +pub fn boot_local_dns(local_config: &YamlLocalConfig) -> Result<(), CliError> { + services::caddy::start(local_config)?; + services::dnsmasq::start()?; + + Ok(()) +} diff --git a/linkup-cli/src/status.rs b/linkup-cli/src/status.rs index c308bd5..db314fa 100644 --- a/linkup-cli/src/status.rs +++ b/linkup-cli/src/status.rs @@ -80,11 +80,9 @@ pub fn status(json: bool, all: bool) -> Result<(), CliError> { }; if !all && !json { - status.services = status + status .services - .into_iter() - .filter(|s| s.status != ServerStatus::Ok || s.component_kind == "local") - .collect(); + .retain(|s| s.status != ServerStatus::Ok || s.component_kind == "local"); } if json { diff --git a/linkup-cli/src/stop.rs b/linkup-cli/src/stop.rs index 0194104..545841b 100644 --- a/linkup-cli/src/stop.rs +++ b/linkup-cli/src/stop.rs @@ -1,12 +1,14 @@ -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; -use crate::signal::{send_sigint, PidError}; +use nix::sys::signal::Signal; + +use crate::signal::{get_pid, send_signal, PidError}; use crate::start::get_state; use crate::{ - linkup_file_path, CliError, LINKUP_CLOUDFLARED_PID, LINKUP_ENV_SEPARATOR, - LINKUP_LOCALSERVER_PID_FILE, + linkup_file_path, services, CliError, LINKUP_CLOUDFLARED_PID, LINKUP_ENV_SEPARATOR, + LINKUP_LOCALDNS_INSTALL, LINKUP_LOCALSERVER_PID_FILE, }; pub fn stop() -> Result<(), CliError> { @@ -27,13 +29,15 @@ pub fn stop() -> Result<(), CliError> { } pub fn shutdown() -> Result<(), CliError> { - let local_stopped = stop_pid_file(LINKUP_LOCALSERVER_PID_FILE); - if local_stopped.is_ok() { - let _ = std::fs::remove_file(linkup_file_path(LINKUP_LOCALSERVER_PID_FILE)); - } - let tunnel_stopped = stop_pid_file(LINKUP_CLOUDFLARED_PID); - if tunnel_stopped.is_ok() { - let _ = std::fs::remove_file(linkup_file_path(LINKUP_CLOUDFLARED_PID)); + let local_stopped = stop_pid_file( + &linkup_file_path(LINKUP_LOCALSERVER_PID_FILE), + Signal::SIGINT, + ); + + let tunnel_stopped = stop_pid_file(&linkup_file_path(LINKUP_CLOUDFLARED_PID), Signal::SIGINT); + + if linkup_file_path(LINKUP_LOCALDNS_INSTALL).exists() { + stop_localdns_services(); } match (local_stopped, tunnel_stopped) { @@ -46,36 +50,32 @@ pub fn shutdown() -> Result<(), CliError> { } } -pub fn stop_pid_file(pid_file: &str) -> Result<(), CliError> { - match get_pid(pid_file) { - Ok(pid) => { - match send_sigint(&pid) { - Ok(_) => Ok(()), - // If we're trying to stop it but it's already died, that's fine - Err(PidError::NoSuchProcess(_)) => Ok(()), - Err(e) => Err(CliError::StopErr(format!( - "Could not send SIGINT to {} pid {}: {}", - pid_file, pid, e - ))), - } - } +pub fn stop_pid_file(pid_file: &Path, signal: Signal) -> Result<(), CliError> { + let stopped = match get_pid(pid_file) { + Ok(pid) => match send_signal(&pid, signal) { + Ok(_) => Ok(()), + Err(PidError::NoSuchProcess(_)) => Ok(()), + Err(e) => Err(CliError::StopErr(format!( + "Could not send {} to {} pid {}: {}", + signal, + pid_file.display(), + pid, + e + ))), + }, Err(PidError::NoPidFile(_)) => Ok(()), Err(e) => Err(CliError::StopErr(format!( "Could not get {} pid: {}", - pid_file, e + pid_file.display(), + e ))), - } -} + }; -fn get_pid(file_name: &str) -> Result { - if let Err(e) = File::open(linkup_file_path(file_name)) { - return Err(PidError::NoPidFile(e.to_string())); + if stopped.is_ok() { + let _ = std::fs::remove_file(pid_file); } - match fs::read_to_string(linkup_file_path(file_name)) { - Ok(content) => Ok(content.trim().to_string()), - Err(e) => Err(PidError::BadPidFile(e.to_string())), - } + stopped } fn remove_service_env(directory: String, config_path: String) -> Result<(), CliError> { @@ -145,3 +145,8 @@ fn remove_service_env(directory: String, config_path: String) -> Result<(), CliE Ok(()) } + +fn stop_localdns_services() { + let _ = services::caddy::stop(); + services::dnsmasq::stop(); +} diff --git a/linkup/src/lib.rs b/linkup/src/lib.rs index b6934a8..28379a5 100644 --- a/linkup/src/lib.rs +++ b/linkup/src/lib.rs @@ -85,9 +85,9 @@ pub fn get_additional_headers( ); } - if !headers.contains_key("X-Forwarded-Host") { + if !headers.contains_key("x-forwarded-host") { additional_headers.insert( - "X-Forwarded-Host".to_string(), + "x-forwarded-host".to_string(), get_target_domain(&url, session_name), ); } @@ -116,7 +116,11 @@ pub fn get_target_service( config: &Session, session_name: &str, ) -> Option<(String, String)> { - let target = Url::parse(&url).unwrap(); + let mut target = Url::parse(&url).unwrap(); + // Ensure always the default port, even when the local server is hit first + target + .set_port(None) + .expect("setting port to None is always valid"); let path = target.path(); // If there was a destination created in a previous linkup, we don't want to @@ -131,13 +135,14 @@ pub fn get_target_service( let url_target = config.domains.get(&get_target_domain(&url, session_name)); // Forwarded hosts persist over the tunnel - let forwarded_host_target = config.domains.get( + let forwarded_host_target = config.domains.get(&get_target_domain( headers.get("x-forwarded-host").unwrap_or( headers .get("X-Forwarded-Host") .unwrap_or(&"does-not-exist".to_string()), ), - ); + session_name, + )); // This is more for e2e tests to work let referer_target = config.domains.get(&get_target_domain( diff --git a/linkup/src/session_allocator.rs b/linkup/src/session_allocator.rs index 2db9a31..374de3c 100644 --- a/linkup/src/session_allocator.rs +++ b/linkup/src/session_allocator.rs @@ -24,6 +24,13 @@ impl SessionAllocator { return Ok((url_name, config)); } + if let Some(forwarded_host) = headers.get("x-forwarded-host") { + let forwarded_host_name = first_subdomain(forwarded_host); + if let Some(config) = self.get_session_config(forwarded_host_name.to_string()).await? { + return Ok((forwarded_host_name, config)); + } + } + if let Some(referer) = headers.get("referer") { let referer_name = first_subdomain(referer); if let Some(config) = self.get_session_config(referer_name.to_string()).await? { diff --git a/worker/src/http_util.rs b/worker/src/http_util.rs index 4fb367e..f29453c 100644 --- a/worker/src/http_util.rs +++ b/worker/src/http_util.rs @@ -42,7 +42,7 @@ pub fn merge_headers( { if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) { if let Ok(header_value) = reqwest::header::HeaderValue::from_str(&value) { - header_map.append(header_name, header_value); + header_map.insert(header_name, header_value); } } }