Skip to content

Commit

Permalink
feat: add local-dns command (#28)
Browse files Browse the repository at this point in the history
### 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 <[email protected]>
Co-authored-by: Augusto César <[email protected]>
  • Loading branch information
augustoccesar and ostenbom authored Oct 2, 2023
1 parent 43eb3d1 commit 16e3bb9
Show file tree
Hide file tree
Showing 16 changed files with 621 additions and 123 deletions.
7 changes: 4 additions & 3 deletions linkup-cli/src/background_tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -133,7 +134,7 @@ fn try_start_tunnel() -> Result<Url, CliError> {
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
Expand Down Expand Up @@ -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));
Expand Down
75 changes: 71 additions & 4 deletions linkup-cli/src/local_config.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
use std::fmt::{self, Display, Formatter};
use std::{
env,
fmt::{self, Display, Formatter},
fs,
};

use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use url::Url;

use linkup::{StorableDomain, StorableRewrite};

use crate::{CliError, LINKUP_CONFIG_ENV};

#[derive(Deserialize, Serialize, Clone)]
pub struct LocalState {
pub linkup: LinkupState,
Expand Down Expand Up @@ -48,20 +54,35 @@ impl Display for ServiceTarget {
}
}

#[derive(Deserialize)]
#[derive(Deserialize, Clone)]
pub struct YamlLocalConfig {
linkup: LinkupConfig,
services: Vec<YamlLocalService>,
domains: Vec<StorableDomain>,
}

#[derive(Deserialize)]
impl YamlLocalConfig {
pub fn top_level_domains(&self) -> Vec<String> {
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::<Vec<String>>()
}
}

#[derive(Deserialize, Clone)]
struct LinkupConfig {
remote: Url,
cache_routes: Option<Vec<String>>,
}

#[derive(Deserialize)]
#[derive(Deserialize, Clone)]
struct YamlLocalService {
name: String,
remote: Url,
Expand Down Expand Up @@ -115,6 +136,52 @@ pub fn config_to_state(yaml_config: YamlLocalConfig, config_path: String) -> Loc
}
}

pub fn config_path(config_arg: &Option<String>) -> Result<String, CliError> {
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<YamlLocalConfig, CliError> {
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::*;
Expand Down
196 changes: 196 additions & 0 deletions linkup-cli/src/local_dns.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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/<domain>");
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<String>) -> 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
}
21 changes: 17 additions & 4 deletions linkup-cli/src/local_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use futures::stream::StreamExt;
use thiserror::Error;

use linkup::*;
use url::Url;

use crate::LINKUP_LOCALSERVER_PORT;

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -238,10 +250,11 @@ fn merge_headers(
extra_headers: &HashMap<String, String>,
) -> 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);
}
}
}
Expand Down
Loading

0 comments on commit 16e3bb9

Please sign in to comment.