From 1b25f07e6472489e78255618861b0e49b81286a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 08:58:07 +0000 Subject: [PATCH 01/17] feat!: remove Caddy TODO: Cleanup the worker from Caddy-related features --- .github/workflows/caddy.yml | 99 ------ .../content/docs/explanation/how-it-works.md | 2 +- linkup-cli/src/commands/health.rs | 21 -- linkup-cli/src/commands/local_dns.rs | 10 - linkup-cli/src/commands/start.rs | 31 -- linkup-cli/src/commands/stop.rs | 1 - linkup-cli/src/commands/update.rs | 17 - linkup-cli/src/main.rs | 2 - linkup-cli/src/release.rs | 42 +-- linkup-cli/src/services/caddy.rs | 314 ------------------ linkup-cli/src/services/dnsmasq.rs | 6 +- linkup-cli/src/services/mod.rs | 2 - 12 files changed, 3 insertions(+), 544 deletions(-) delete mode 100644 .github/workflows/caddy.yml delete mode 100644 linkup-cli/src/services/caddy.rs diff --git a/.github/workflows/caddy.yml b/.github/workflows/caddy.yml deleted file mode 100644 index 38595d5b..00000000 --- a/.github/workflows/caddy.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Build caddy with linkup modules - -on: - workflow_dispatch: - inputs: - tag_name: - description: 'Tag to use for the release (e.g., 1.0.0)' - required: true - push: - tags: - - '[0-9][0-9]*.[0-9][0-9]*.[0-9][0-9]*' - -jobs: - build-and-release: - name: Build and Release Caddy with Linkup Modules - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - arch: [amd64, arm64] - steps: - # Set up Go environment - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.23' - - - name: Install xcaddy - run: | - go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest - - # Build Caddy with custom module - - name: Build Caddy with Custom Module - run: | - if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then - TARGET_OS="linux" - else - TARGET_OS="darwin" - fi - xcaddy build \ - --output "caddy-${TARGET_OS}-${{ matrix.arch }}" \ - --with github.com/mentimeter/caddy-dns-linkup \ - --with github.com/mentimeter/caddy-storage-linkup - env: - GOBIN: $HOME/go/bin # Ensure Go binaries are in the PATH - - # Archive the binary - - name: Archive Caddy Binary - run: | - if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then - TARGET_OS="linux" - else - TARGET_OS="darwin" - fi - tar -czvf caddy-${TARGET_OS}-${{ matrix.arch }}.tar.gz caddy-${TARGET_OS}-${{ matrix.arch }} - shell: bash - - - name: Get Release Info - id: get_release - uses: actions/github-script@v7 - with: - script: | - let tagName; - if (context.eventName === 'workflow_dispatch') { - tagName = core.getInput('tag_name'); - console.log(`Tag name from workflow_dispatch: ${tagName}`); - } else if (context.eventName === 'push' && context.ref.startsWith('refs/tags/')) { - tagName = context.ref.replace('refs/tags/', ''); - console.log(`Tag name from push: ${tagName}`); - } else { - throw new Error('This workflow must be triggered by a push to a tag or a manual dispatch with a tag_name input.'); - } - if (!tagName) { - throw new Error('Tag name is empty.'); - } - const releases = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo - }); - const release = releases.data.find(r => r.tag_name === tagName); - if (!release) { - throw new Error(`Release with tag ${tagName} not found.`); - } - console.log(`Found release: ${release.name}`); - core.setOutput('upload_url', release.upload_url); - env: - INPUT_TAG_NAME: ${{ inputs.tag_name }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Upload binary to the release - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./caddy-${{ matrix.os == 'ubuntu-latest' && 'linux' || 'darwin' }}-${{ matrix.arch }}.tar.gz - asset_name: caddy-${{ matrix.os == 'ubuntu-latest' && 'linux' || 'darwin' }}-${{ matrix.arch }}.tar.gz - asset_content_type: application/gzip \ No newline at end of file diff --git a/docs/src/content/docs/explanation/how-it-works.md b/docs/src/content/docs/explanation/how-it-works.md index e1df7fb1..8887754d 100644 --- a/docs/src/content/docs/explanation/how-it-works.md +++ b/docs/src/content/docs/explanation/how-it-works.md @@ -122,4 +122,4 @@ In its default mode, Linkup has a fairly strong dependency on the network. For f In order to speed up cases where the network might be a bottleneck, Linkup provides a local DNS mode that is optionally installable on developers' machines. Local DNS will resolve your application's domains directly to servers running on your local machine. This means that all requests that could have been handled directly by your local machine will not go over the public internet. Linkup also has the ability to manage certificates associated with these local domains to make the experience as seamless as possible. -Currently, linkup local DNS uses [dnsmasq](https://www.dnsmasq.org/) to provide local DNS resolution. And [caddy](https://caddyserver.com/) to provide tls certificates. \ No newline at end of file +Currently, linkup local DNS uses [dnsmasq](https://www.dnsmasq.org/) to provide local DNS resolution. \ No newline at end of file diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index ed04d2b8..66e06b01 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -80,7 +80,6 @@ struct OrphanProcess { #[derive(Debug, Serialize)] struct BackgroudServices { linkup_server: BackgroundServiceHealth, - caddy: BackgroundServiceHealth, dnsmasq: BackgroundServiceHealth, cloudflared: BackgroundServiceHealth, possible_orphan_processes: Vec, @@ -119,19 +118,6 @@ impl BackgroudServices { BackgroundServiceHealth::NotInstalled }; - let caddy = if services::is_caddy_installed() { - match services::Caddy::new().running_pid() { - Some(pid) => { - managed_pids.push(pid); - - BackgroundServiceHealth::Running(pid.as_u32()) - } - None => BackgroundServiceHealth::Stopped, - } - } else { - BackgroundServiceHealth::NotInstalled - }; - let cloudflared = if services::is_cloudflared_installed() { match services::CloudflareTunnel::new().running_pid() { Some(pid) => { @@ -147,7 +133,6 @@ impl BackgroudServices { Self { linkup_server, - caddy, dnsmasq, cloudflared, possible_orphan_processes: find_potential_orphan_processes(managed_pids), @@ -284,12 +269,6 @@ impl Display for Health { BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, } - write!(f, " - Caddy ")?; - match &self.background_services.caddy { - BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, - BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, - BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, - } write!(f, " - dnsmasq ")?; match &self.background_services.dnsmasq { BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 0b5c6f13..f9043a94 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -48,12 +48,6 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_resolver_dir()?; install_resolvers(&input_config.top_level_domains())?; - println!("Installing Caddy..."); - - services::Caddy::install() - .await - .map_err(|e| CliError::LocalDNSInstall(e.to_string()))?; - Ok(()) } @@ -71,10 +65,6 @@ pub async fn uninstall(config_arg: &Option) -> Result<()> { uninstall_resolvers(&input_config.top_level_domains())?; - services::Caddy::uninstall() - .await - .map_err(|e| CliError::LocalDNSUninstall(e.to_string()))?; - Ok(()) } diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 0e5ba7ee..3474d5b4 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -49,28 +49,8 @@ pub async fn start( let local_server = services::LocalServer::new(); let cloudflare_tunnel = services::CloudflareTunnel::new(); - let caddy = services::Caddy::new(); let dnsmasq = services::Dnsmasq::new(); - #[cfg(target_os = "linux")] - { - use crate::{is_sudo, sudo_su}; - match (caddy.should_start(&state.domain_strings()), is_sudo()) { - // Should start Caddy and is not sudo - (Ok(true), false) => { - println!( - "On linux binding port 443 and 80 requires sudo. And this is necessary to start caddy." - ); - - sudo_su()?; - } - // Should not start Caddy or should start Caddy but is already sudo - (Ok(false), _) | (Ok(true), true) => (), - // Can't check if should start Caddy - (Err(error), _) => log::error!("Failed to check if should start Caddy: {}", error), - } - } - let mut display_thread: Option> = None; let display_channel = sync::mpsc::channel::(); @@ -82,7 +62,6 @@ pub async fn start( &[ services::LocalServer::NAME, services::CloudflareTunnel::NAME, - services::Caddy::NAME, services::Dnsmasq::NAME, ], status_update_channel.1, @@ -113,16 +92,6 @@ pub async fn start( } } - if exit_error.is_none() { - match caddy - .run_with_progress(&mut state, status_update_channel.0.clone()) - .await - { - Ok(_) => (), - Err(err) => exit_error = Some(Box::new(err)), - } - } - if exit_error.is_none() { match dnsmasq .run_with_progress(&mut state, status_update_channel.0.clone()) diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 7c689b26..6993d239 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -31,7 +31,6 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<(), CliError> { services::LocalServer::new().stop(); services::CloudflareTunnel::new().stop(); - services::Caddy::new().stop(); services::Dnsmasq::new().stop(); println!("Stopped linkup"); diff --git a/linkup-cli/src/commands/update.rs b/linkup-cli/src/commands/update.rs index 77acc184..5ed0b1e4 100644 --- a/linkup-cli/src/commands/update.rs +++ b/linkup-cli/src/commands/update.rs @@ -29,16 +29,6 @@ pub async fn update(args: &Args) -> Result<(), CliError> { fs::rename(&new_linkup_path, ¤t_linkup_path) .expect("failed to move the new exe as the current exe"); - let new_caddy_path = update.caddy.download_decompressed("caddy").await.unwrap(); - - let current_caddy_path = get_caddy_path(); - let bkp_caddy_path = current_caddy_path.with_extension("bkp"); - - fs::rename(¤t_caddy_path, &bkp_caddy_path) - .expect("failed to move the current exe into a backup"); - fs::rename(&new_caddy_path, ¤t_caddy_path) - .expect("failed to move the new exe as the current exe"); - println!("Finished update!"); } None => { @@ -61,10 +51,3 @@ pub fn update_command() -> String { InstallationMethod::Manual | InstallationMethod::Cargo => "linkup update".to_string(), } } - -fn get_caddy_path() -> PathBuf { - let mut path = linkup_bin_dir_path(); - path.push("caddy"); - - path -} diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 67ef57fc..4edc1f6a 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -143,8 +143,6 @@ 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 start DNSMasq: {0}")] StartDNSMasq(String), #[error("could not load config to {0}: {1}")] diff --git a/linkup-cli/src/release.rs b/linkup-cli/src/release.rs index fabab276..169bc2dc 100644 --- a/linkup-cli/src/release.rs +++ b/linkup-cli/src/release.rs @@ -113,42 +113,6 @@ impl Release { asset } - - /// Examples of Caddy asset files: - /// - caddy-darwin-amd64.tar.gz - /// - caddy-darwin-arm64.tar.gz - /// - caddy-linux-amd64.tar.gz - /// - caddy-linux-arm64.tar.gz - pub fn caddy_asset(&self, os: &str, arch: &str) -> Option { - let lookup_os = match os { - "macos" => "darwin", - "linux" => "linux", - lookup_os => lookup_os, - }; - - let lookup_arch = match arch { - "x86_64" => "amd64", - "aarch64" => "arm64", - lookup_arch => lookup_arch, - }; - - let asset = self - .assets - .iter() - .find(|asset| asset.name == format!("caddy-{}-{}.tar.gz", lookup_os, lookup_arch)) - .cloned(); - - if asset.is_none() { - log::debug!( - "Caddy release for OS '{}' and ARCH '{}' not found on version {}", - lookup_os, - lookup_arch, - &self.version - ); - } - - asset - } } #[derive(Serialize, Deserialize)] @@ -159,7 +123,6 @@ struct CachedLatestRelease { pub struct Update { pub linkup: Asset, - pub caddy: Asset, } pub async fn available_update(current_version: &Version) -> Option { @@ -217,14 +180,11 @@ pub async fn available_update(current_version: &Version) -> Option { return None; } - let caddy = latest_release - .caddy_asset(os, arch) - .expect("Caddy asset to be present on a release"); let linkup = latest_release .linkup_asset(os, arch) .expect("Linkup asset to be present on a release"); - Some(Update { linkup, caddy }) + Some(Update { linkup }) } async fn fetch_latest_release() -> Result { diff --git a/linkup-cli/src/services/caddy.rs b/linkup-cli/src/services/caddy.rs deleted file mode 100644 index 0c223cc4..00000000 --- a/linkup-cli/src/services/caddy.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::{env, fs, path::PathBuf, process::Command}; - -use url::Url; - -use crate::{ - commands::local_dns, current_version, linkup_bin_dir_path, linkup_dir_path, linkup_file_path, - local_config::LocalState, release, Version, -}; - -use super::{ - get_running_pid, local_server::LINKUP_LOCAL_SERVER_PORT, stop_pid_file, BackgroundService, Pid, - PidError, Signal, -}; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("Failed to start the Caddy service")] - Starting, - #[error("Failed while handing file: {0}")] - FileHandling(#[from] std::io::Error), - #[error("Failed to stop pid: {0}")] - StoppingPid(#[from] PidError), -} - -#[derive(thiserror::Error, Debug)] -pub enum InstallError { - #[error("Failed while handing file: {0}")] - FileHandling(#[from] std::io::Error), - #[error("Failed to fetch release information: {0}")] - FetchError(#[from] reqwest::Error), - #[error("Release not found for version {0}")] - ReleaseNotFound(Version), - #[error("Caddy asset not found on release for version {0}")] - AssetNotFound(Version), - #[error("Failed to download Caddy asset: {0}")] - AssetDownload(String), -} - -#[derive(thiserror::Error, Debug)] -pub enum UninstallError { - #[error("Failed while handing file: {0}")] - FileHandling(#[from] std::io::Error), -} - -pub struct Caddy { - caddyfile_path: PathBuf, - stdout_file_path: PathBuf, - stderr_file_path: PathBuf, - pidfile_path: PathBuf, -} - -impl Caddy { - pub fn new() -> Self { - Self { - caddyfile_path: linkup_file_path("Caddyfile"), - stdout_file_path: linkup_file_path("caddy-stdout"), - stderr_file_path: linkup_file_path("caddy-stderr"), - pidfile_path: linkup_file_path("caddy-pid"), - } - } - - pub async fn install() -> Result<(), InstallError> { - let bin_dir_path = linkup_bin_dir_path(); - fs::create_dir_all(&bin_dir_path)?; - - let mut caddy_path = bin_dir_path.clone(); - caddy_path.push("caddy"); - - if fs::exists(&caddy_path)? { - log::debug!( - "Caddy executable already exists on {}", - &bin_dir_path.display() - ); - return Ok(()); - } - - let version = current_version(); - match release::fetch_release(&version).await? { - Some(release) => { - let os = env::consts::OS; - let arch = env::consts::ARCH; - - match release.caddy_asset(os, arch) { - Some(asset) => match asset.download_decompressed("caddy").await { - Ok(downloaded_caddy_path) => { - log::debug!( - "Moving downloaded Caddy file from {:?} to {:?}", - &downloaded_caddy_path, - &caddy_path - ); - - fs::copy(&downloaded_caddy_path, &caddy_path)?; - fs::remove_file(&downloaded_caddy_path)?; - } - Err(error) => return Err(InstallError::AssetDownload(error.to_string())), - }, - None => { - log::warn!( - "Failed to find Caddy asset on release for version {}", - &version - ); - - return Err(InstallError::AssetNotFound(version.clone())); - } - } - } - None => { - log::warn!("Failed to find release for version {}", &version); - - return Err(InstallError::ReleaseNotFound(version.clone())); - } - } - - Ok(()) - } - - pub async fn uninstall() -> Result<(), UninstallError> { - let mut path = linkup_bin_dir_path(); - path.push("caddy"); - - if !fs::exists(&path)? { - log::debug!("Caddy executable does not exist on {}", &path.display()); - - return Ok(()); - } - - fs::remove_file(&path)?; - - Ok(()) - } - - fn start(&self, worker_url: &Url, worker_token: &str, domains: &[String]) -> Result<(), Error> { - log::debug!("Starting {}", Self::NAME); - - let domains_and_subdomains: Vec = domains - .iter() - .map(|domain| format!("{domain}, *.{domain}")) - .collect(); - - self.write_caddyfile(worker_url, worker_token, &domains_and_subdomains)?; - - let stdout_file = fs::File::create(&self.stdout_file_path)?; - let stderr_file = fs::File::create(&self.stderr_file_path)?; - - #[cfg(target_os = "macos")] - let status = Command::new("./bin/caddy") - .current_dir(linkup_dir_path()) - .arg("start") - .arg("--pidfile") - .arg(&self.pidfile_path) - .stdout(stdout_file) - .stderr(stderr_file) - .status()?; - - #[cfg(target_os = "linux")] - let status = { - // To make sure that the local user is the owner of the pidfile and not root, - // we create it before running the caddy command. - let _ = fs::File::create(&self.pidfile_path)?; - - Command::new("sudo") - .current_dir(linkup_dir_path()) - .arg("./bin/caddy") - .arg("start") - .arg("--pidfile") - .arg(&self.pidfile_path) - .stdin(std::process::Stdio::null()) - .stdout(stdout_file) - .stderr(stderr_file) - .status()? - }; - - if !status.success() { - return Err(Error::Starting); - } - - Ok(()) - } - - pub fn stop(&self) { - log::debug!("Stopping {}", Self::NAME); - - stop_pid_file(&self.pidfile_path, Signal::Term); - } - - fn write_caddyfile( - &self, - worker_url: &Url, - worker_token: &str, - domains: &[String], - ) -> Result<(), Error> { - let worker_url_str = worker_url.as_str().trim_end_matches('/'); - let logfile_path = self.stdout_file_path.display(); - let domains_str = domains.join(", "); - - let caddy_template = format!( - " - {{ - http_port 80 - https_port 443 - log {{ - output file {logfile_path} - }} - storage linkup {{ - worker_url \"{worker_url_str}\" - token \"{worker_token}\" - }} - }} - - {domains_str} {{ - reverse_proxy localhost:{LINKUP_LOCAL_SERVER_PORT} - tls {{ - resolvers 1.1.1.1 - dns linkup {{ - worker_url \"{worker_url_str}\" - token \"{worker_token}\" - }} - }} - }} - ", - ); - - fs::write(&self.caddyfile_path, caddy_template)?; - - Ok(()) - } - - pub fn should_start(&self, domains: &[String]) -> Result { - if !is_installed() { - return Ok(false); - } - - let resolvers = local_dns::list_resolvers()?; - - Ok(domains.iter().any(|domain| resolvers.contains(domain))) - } - - pub fn running_pid(&self) -> Option { - get_running_pid(&self.pidfile_path) - } -} - -impl BackgroundService for Caddy { - const NAME: &str = "Caddy"; - - async fn run_with_progress( - &self, - state: &mut LocalState, - status_sender: std::sync::mpsc::Sender, - ) -> Result<(), Error> { - let domains = &state.domain_strings(); - - match self.should_start(domains) { - Ok(true) => (), - Ok(false) => { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Skipped, - "Local DNS not installed", - ); - - return Ok(()); - } - Err(err) => { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Skipped, - "Failed to read resolvers folder", - ); - - log::warn!("Failed to read resolvers folder: {}", err); - - return Ok(()); - } - } - - self.notify_update(&status_sender, super::RunStatus::Starting); - - if self.running_pid().is_some() { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Started, - "Was already running", - ); - - return Ok(()); - } - - if let Err(e) = self.start( - &state.linkup.worker_url, - &state.linkup.worker_token, - domains, - ) { - self.notify_update_with_details( - &status_sender, - super::RunStatus::Error, - "Failed to start", - ); - - return Err(e); - } - - self.notify_update(&status_sender, super::RunStatus::Started); - - Ok(()) - } -} - -pub fn is_installed() -> bool { - let mut caddy_path = linkup_bin_dir_path(); - caddy_path.push("caddy"); - - caddy_path.exists() -} diff --git a/linkup-cli/src/services/dnsmasq.rs b/linkup-cli/src/services/dnsmasq.rs index 4c46544e..10783f5e 100644 --- a/linkup-cli/src/services/dnsmasq.rs +++ b/linkup-cli/src/services/dnsmasq.rs @@ -7,7 +7,7 @@ use std::{ use crate::{commands::local_dns, linkup_dir_path, linkup_file_path, local_config::LocalState}; -use super::{caddy, get_running_pid, stop_pid_file, BackgroundService, Pid, PidError, Signal}; +use super::{get_running_pid, stop_pid_file, BackgroundService, Pid, PidError, Signal}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -87,10 +87,6 @@ pid-file={}\n", } fn should_start(&self, domains: &[String]) -> Result { - if !caddy::is_installed() { - return Ok(false); - } - let resolvers = local_dns::list_resolvers()?; Ok(domains.iter().any(|domain| resolvers.contains(domain))) diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 3a8d98da..9b95a5bd 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -5,14 +5,12 @@ use std::{fmt::Display, sync}; use sysinfo::{get_current_pid, ProcessRefreshKind, RefreshKind, System}; use thiserror::Error; -mod caddy; mod cloudflare_tunnel; mod dnsmasq; mod local_server; pub use local_server::LocalServer; pub use sysinfo::{Pid, Signal}; -pub use {caddy::is_installed as is_caddy_installed, caddy::Caddy}; pub use { cloudflare_tunnel::is_installed as is_cloudflared_installed, cloudflare_tunnel::CloudflareTunnel, From 5a9cdd780f21edb3844067a3976b4c3a61f98789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 09:13:45 +0000 Subject: [PATCH 02/17] feat: add localdns feature flag --- linkup-cli/Cargo.toml | 4 +++ linkup-cli/build.rs | 10 +++++++ linkup-cli/src/commands/health.rs | 44 ++++++++++++++++++++++--------- linkup-cli/src/commands/mod.rs | 2 ++ linkup-cli/src/commands/start.rs | 3 +++ linkup-cli/src/commands/stop.rs | 1 + linkup-cli/src/main.rs | 2 ++ linkup-cli/src/services/mod.rs | 2 ++ 8 files changed, 56 insertions(+), 12 deletions(-) diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index abd95073..c24d4532 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -47,3 +47,7 @@ flate2 = "1.0.35" [dev-dependencies] mockall = "0.13.1" mockito = "1.6.1" + +[features] +default = [] +localdns = [] diff --git a/linkup-cli/build.rs b/linkup-cli/build.rs index 7d10252e..217847f8 100644 --- a/linkup-cli/build.rs +++ b/linkup-cli/build.rs @@ -4,6 +4,16 @@ use std::path::Path; use std::process::Command; fn main() { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + if target_os == "macos" { + println!("cargo:rustc-cfg=feature=\"localdns\""); + } + + if target_os == "linux" && env::var("CARGO_FEATURE_LOCALDNS").is_ok() { + panic!("The `localdns` feature is not supported on Linux"); + } + println!("cargo::rerun-if-changed=../worker/src"); let out_dir = env::var("OUT_DIR").expect("OUT_DIR to be set"); diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index 66e06b01..a998a5e6 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -10,6 +10,7 @@ use serde::Serialize; use crate::{linkup_dir_path, local_config::LocalState, services, CliError}; +#[cfg(feature = "localdns")] use super::local_dns; #[derive(clap::Args)] @@ -80,6 +81,7 @@ struct OrphanProcess { #[derive(Debug, Serialize)] struct BackgroudServices { linkup_server: BackgroundServiceHealth, + #[cfg(feature = "localdns")] dnsmasq: BackgroundServiceHealth, cloudflared: BackgroundServiceHealth, possible_orphan_processes: Vec, @@ -105,6 +107,7 @@ impl BackgroudServices { None => BackgroundServiceHealth::Stopped, }; + #[cfg(feature = "localdns")] let dnsmasq = if services::is_dnsmasq_installed() { match services::Dnsmasq::new().running_pid() { Some(pid) => { @@ -133,6 +136,7 @@ impl BackgroudServices { Self { linkup_server, + #[cfg(feature = "localdns")] dnsmasq, cloudflared, possible_orphan_processes: find_potential_orphan_processes(managed_pids), @@ -193,11 +197,13 @@ impl Linkup { } } +#[cfg(feature = "localdns")] #[derive(Debug, Serialize)] struct LocalDNS { resolvers: Vec, } +#[cfg(feature = "localdns")] impl LocalDNS { fn load() -> Result { Ok(Self { @@ -212,6 +218,7 @@ struct Health { session: Option, background_services: BackgroudServices, linkup: Linkup, + #[cfg(feature = "localdns")] local_dns: LocalDNS, } @@ -231,6 +238,7 @@ impl Health { session, background_services: BackgroudServices::load(), linkup: Linkup::load()?, + #[cfg(feature = "localdns")] local_dns: LocalDNS::load()?, }) } @@ -269,12 +277,21 @@ impl Display for Health { BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, } - write!(f, " - dnsmasq ")?; - match &self.background_services.dnsmasq { - BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, - BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, - BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, + + #[cfg(feature = "localdns")] + { + write!(f, " - dnsmasq ")?; + match &self.background_services.dnsmasq { + BackgroundServiceHealth::NotInstalled => { + writeln!(f, "{}", "NOT INSTALLED".yellow())? + } + BackgroundServiceHealth::Stopped => writeln!(f, "{}", "NOT RUNNING".yellow())?, + BackgroundServiceHealth::Running(pid) => { + writeln!(f, "{} ({})", "RUNNING".blue(), pid)? + } + } } + write!(f, " - Cloudflared ")?; match &self.background_services.cloudflared { BackgroundServiceHealth::NotInstalled => writeln!(f, "{}", "NOT INSTALLED".yellow())?, @@ -300,13 +317,16 @@ impl Display for Health { } } - write!(f, "{}", "Local DNS resolvers:".bold().italic())?; - if self.local_dns.resolvers.is_empty() { - writeln!(f, " {}", "EMPTY".yellow())?; - } else { - writeln!(f)?; - for file in &self.local_dns.resolvers { - writeln!(f, " - {}", file)?; + #[cfg(feature = "localdns")] + { + write!(f, "{}", "Local DNS resolvers:".bold().italic())?; + if self.local_dns.resolvers.is_empty() { + writeln!(f, " {}", "EMPTY".yellow())?; + } else { + writeln!(f)?; + for file in &self.local_dns.resolvers { + writeln!(f, " - {}", file)?; + } } } diff --git a/linkup-cli/src/commands/mod.rs b/linkup-cli/src/commands/mod.rs index 808ab18f..7d73affa 100644 --- a/linkup-cli/src/commands/mod.rs +++ b/linkup-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod completion; pub mod deploy; pub mod health; pub mod local; +#[cfg(feature = "localdns")] pub mod local_dns; pub mod preview; pub mod remote; @@ -18,6 +19,7 @@ pub use {deploy::deploy, deploy::DeployArgs}; pub use {deploy::destroy, deploy::DestroyArgs}; pub use {health::health, health::Args as HealthArgs}; pub use {local::local, local::Args as LocalArgs}; +#[cfg(feature = "localdns")] pub use {local_dns::local_dns, local_dns::Args as LocalDnsArgs}; pub use {preview::preview, preview::Args as PreviewArgs}; pub use {remote::remote, remote::Args as RemoteArgs}; diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 3474d5b4..088de0fb 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -49,6 +49,7 @@ pub async fn start( let local_server = services::LocalServer::new(); let cloudflare_tunnel = services::CloudflareTunnel::new(); + #[cfg(feature = "localdns")] let dnsmasq = services::Dnsmasq::new(); let mut display_thread: Option> = None; @@ -62,6 +63,7 @@ pub async fn start( &[ services::LocalServer::NAME, services::CloudflareTunnel::NAME, + #[cfg(feature = "localdns")] services::Dnsmasq::NAME, ], status_update_channel.1, @@ -92,6 +94,7 @@ pub async fn start( } } + #[cfg(feature = "localdns")] if exit_error.is_none() { match dnsmasq .run_with_progress(&mut state, status_update_channel.0.clone()) diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 6993d239..4d3a4944 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -31,6 +31,7 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<(), CliError> { services::LocalServer::new().stop(); services::CloudflareTunnel::new().stop(); + #[cfg(feature = "localdns")] services::Dnsmasq::new().stop(); println!("Stopped linkup"); diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 4edc1f6a..6ae37fef 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -231,6 +231,7 @@ enum Commands { #[clap(about = "View linkup component and service status")] Status(commands::StatusArgs), + #[cfg(feature = "localdns")] #[clap(about = "Speed up your local environment by routing traffic locally when possible")] LocalDNS(commands::LocalDnsArgs), @@ -286,6 +287,7 @@ async fn main() -> Result<()> { Commands::Local(args) => commands::local(args).await, Commands::Remote(args) => commands::remote(args).await, Commands::Status(args) => commands::status(args), + #[cfg(feature = "localdns")] Commands::LocalDNS(args) => commands::local_dns(args, &cli.config).await, Commands::Completion(args) => commands::completion(args), Commands::Preview(args) => commands::preview(args, &cli.config).await, diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 9b95a5bd..361e09fe 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -6,6 +6,7 @@ use sysinfo::{get_current_pid, ProcessRefreshKind, RefreshKind, System}; use thiserror::Error; mod cloudflare_tunnel; +#[cfg(feature = "localdns")] mod dnsmasq; mod local_server; @@ -15,6 +16,7 @@ pub use { cloudflare_tunnel::is_installed as is_cloudflared_installed, cloudflare_tunnel::CloudflareTunnel, }; +#[cfg(feature = "localdns")] pub use {dnsmasq::is_installed as is_dnsmasq_installed, dnsmasq::Dnsmasq}; use crate::local_config::LocalState; From 6d0da1a427565f40c23fb204163d7a4dc7418026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 10:25:01 +0000 Subject: [PATCH 03/17] lint: fix clippy offenses --- linkup-cli/src/commands/local_dns.rs | 2 +- linkup-cli/src/commands/update.rs | 6 ++---- linkup-cli/src/release.rs | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index f9043a94..d5e7dc21 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -8,7 +8,7 @@ use clap::Subcommand; use crate::{ commands, is_sudo, local_config::{config_path, get_config}, - services, sudo_su, CliError, Result, + sudo_su, CliError, Result, }; #[derive(clap::Args)] diff --git a/linkup-cli/src/commands/update.rs b/linkup-cli/src/commands/update.rs index 5ed0b1e4..581c6c1e 100644 --- a/linkup-cli/src/commands/update.rs +++ b/linkup-cli/src/commands/update.rs @@ -1,7 +1,5 @@ -use crate::{ - current_version, linkup_bin_dir_path, linkup_exe_path, release, CliError, InstallationMethod, -}; -use std::{fs, path::PathBuf}; +use crate::{current_version, linkup_exe_path, release, CliError, InstallationMethod}; +use std::fs; #[derive(clap::Args)] pub struct Args { diff --git a/linkup-cli/src/release.rs b/linkup-cli/src/release.rs index 169bc2dc..5d1c5b09 100644 --- a/linkup-cli/src/release.rs +++ b/linkup-cli/src/release.rs @@ -212,6 +212,7 @@ async fn fetch_latest_release() -> Result { client.execute(req).await?.json().await } +#[allow(dead_code)] pub async fn fetch_release(version: &Version) -> Result, reqwest::Error> { let tag = version.to_string(); From 3ac90e69e14a92f4a2da46a75841b2b9c26eea89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 10:58:13 +0000 Subject: [PATCH 04/17] feat: implement HTTPS local-server --- Cargo.lock | 197 +++++++++++++++- linkup-cli/src/commands/server.rs | 44 +++- linkup-cli/src/commands/status.rs | 5 +- linkup-cli/src/main.rs | 9 +- linkup-cli/src/services/cloudflare_tunnel.rs | 7 +- linkup-cli/src/services/local_server.rs | 6 +- local-server/Cargo.toml | 9 +- local-server/src/certificates/mod.rs | 214 ++++++++++++++++++ .../src/certificates/wildcard_sni_resolver.rs | 92 ++++++++ local-server/src/lib.rs | 62 +++-- 10 files changed, 603 insertions(+), 42 deletions(-) create mode 100644 local-server/src/certificates/mod.rs create mode 100644 local-server/src/certificates/wildcard_sni_resolver.rs diff --git a/Cargo.lock b/Cargo.lock index 2cd6d80b..8010ea62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,51 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -177,7 +222,7 @@ dependencies = [ "rustversion", "serde", "sync_wrapper", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -213,7 +258,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -259,6 +304,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56bac90848f6a9393ac03c63c640925c4b7c8ca21654de40d53f55964667c7d8" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower 0.4.13", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -659,6 +728,20 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1553,17 +1636,20 @@ name = "linkup-local-server" version = "0.1.0" dependencies = [ "axum 0.8.1", + "axum-server", "futures", "http", "hyper", "hyper-rustls", "hyper-util", "linkup", + "rcgen", "rustls", "rustls-native-certs", + "rustls-pemfile", "thiserror 2.0.11", "tokio", - "tower", + "tower 0.5.2", "tower-http", ] @@ -1809,12 +1895,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1833,6 +1938,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -1912,6 +2026,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2142,6 +2266,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -2227,7 +2365,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tower", + "tower 0.5.2", "tower-service", "url", "wasm-bindgen", @@ -2319,6 +2457,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.43" @@ -2975,6 +3122,21 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3676,6 +3838,24 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.4.0" @@ -3687,6 +3867,15 @@ dependencies = [ "rustix", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index e634ee57..6aed3f5c 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,6 +1,8 @@ -use std::fs; - use crate::CliError; +use linkup::MemoryStringStore; +use std::fs; +use std::path::{Path, PathBuf}; +use tokio::select; #[derive(clap::Args)] pub struct Args { @@ -8,15 +10,43 @@ pub struct Args { pidfile: String, } -pub async fn server(args: &Args) -> Result<(), CliError> { +pub async fn server(args: &Args, certs_dir: &Path) -> Result<(), CliError> { let pid = std::process::id(); fs::write(&args.pidfile, pid.to_string())?; - let res = linkup_local_server::start_server().await; + let config_store = MemoryStringStore::default(); + + let http_config_store = config_store.clone(); + let handler_http = tokio::spawn(async move { + linkup_local_server::start_server_http(http_config_store) + .await + .unwrap(); + }); + + #[cfg(feature = "localdns")] + let handler_https = { + let https_config_store = config_store.clone(); + let https_certs_dir = PathBuf::from(certs_dir); + + Some(tokio::spawn(async move { + linkup_local_server::start_server_https(https_config_store, &https_certs_dir).await; + })) + }; + + #[cfg(not(feature = "localdns"))] + let handler_https: Option> = None; - if let Err(pid_file_err) = fs::remove_file(&args.pidfile) { - eprintln!("Failed to remove pidfile: {}", pid_file_err); + match handler_https { + Some(handler_https) => { + select! { + _ = handler_http => (), + _ = handler_https => (), + } + } + None => { + handler_http.await.unwrap(); + } } - res.map_err(|e| e.into()) + Ok(()) } diff --git a/linkup-cli/src/commands/status.rs b/linkup-cli/src/commands/status.rs index 64655fbc..f7ea27bb 100644 --- a/linkup-cli/src/commands/status.rs +++ b/linkup-cli/src/commands/status.rs @@ -9,11 +9,10 @@ use std::{ thread::{self, sleep}, time::Duration, }; -use url::Url; use crate::{ local_config::{LocalService, LocalState, ServiceTarget}, - CliError, LINKUP_LOCALSERVER_PORT, + services, CliError, }; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -282,7 +281,7 @@ pub fn format_state_domains(session_name: &str, domains: &[StorableDomain]) -> V } fn linkup_services(state: &LocalState) -> Vec { - let local_url = Url::parse(&format!("http://localhost:{}", LINKUP_LOCALSERVER_PORT)).unwrap(); + let local_url = services::LocalServer::url(); vec![ LocalService { diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 6ae37fef..a1897dff 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -15,7 +15,6 @@ mod worker_client; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const LINKUP_CONFIG_ENV: &str = "LINKUP_CONFIG"; -const LINKUP_LOCALSERVER_PORT: u16 = 9066; const LINKUP_DIR: &str = ".linkup"; const LINKUP_STATE_FILE: &str = "state"; @@ -62,6 +61,12 @@ pub fn linkup_bin_dir_path() -> PathBuf { path } +pub fn linkup_certs_dir_path() -> PathBuf { + let mut path = linkup_dir_path(); + path.push("certs"); + path +} + pub fn linkup_file_path(file: &str) -> PathBuf { let mut path = linkup_dir_path(); path.push(file); @@ -291,7 +296,7 @@ async fn main() -> Result<()> { Commands::LocalDNS(args) => commands::local_dns(args, &cli.config).await, Commands::Completion(args) => commands::completion(args), Commands::Preview(args) => commands::preview(args, &cli.config).await, - Commands::Server(args) => commands::server(args).await, + Commands::Server(args) => commands::server(args, &linkup_certs_dir_path()).await, Commands::Uninstall(args) => commands::uninstall(args), Commands::Update(args) => commands::update(args).await, Commands::Deploy(args) => commands::deploy(args).await.map_err(CliError::from), diff --git a/linkup-cli/src/services/cloudflare_tunnel.rs b/linkup-cli/src/services/cloudflare_tunnel.rs index cb6e4e54..7f3f3635 100644 --- a/linkup-cli/src/services/cloudflare_tunnel.rs +++ b/linkup-cli/src/services/cloudflare_tunnel.rs @@ -17,10 +17,7 @@ use serde::{Deserialize, Serialize}; use tokio::time::sleep; use url::Url; -use crate::{ - linkup_file_path, local_config::LocalState, worker_client::WorkerClient, - LINKUP_LOCALSERVER_PORT, -}; +use crate::{linkup_file_path, local_config::LocalState, worker_client::WorkerClient}; use super::{get_running_pid, stop_pid_file, BackgroundService, Pid, PidError, Signal}; @@ -351,7 +348,7 @@ fn create_config_yml(tunnel_id: &str) -> Result<(), Error> { let credentials_file_path_str = credentials_file_path.to_string_lossy().to_string(); let config = Config { - url: format!("http://localhost:{}", LINKUP_LOCALSERVER_PORT), + url: "http://localhost".to_string(), tunnel: tunnel_id.to_string(), credentials_file: credentials_file_path_str, }; diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index 75f21514..c60b62d9 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -19,8 +19,6 @@ use crate::{ use super::{get_running_pid, stop_pid_file, BackgroundService, Pid, PidError, Signal}; -pub const LINKUP_LOCAL_SERVER_PORT: u16 = 9066; - #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Failed while handing file: {0}")] @@ -48,9 +46,9 @@ impl LocalServer { } } + /// For internal communication to local-server, we only use the port 80 (HTTP). pub fn url() -> Url { - Url::parse(&format!("http://localhost:{}", LINKUP_LOCAL_SERVER_PORT)) - .expect("linkup url invalid") + Url::parse("http://localhost:80").expect("linkup url invalid") } fn start(&self) -> Result<(), Error> { diff --git a/local-server/Cargo.toml b/local-server/Cargo.toml index 41b8e375..a02f042f 100644 --- a/local-server/Cargo.toml +++ b/local-server/Cargo.toml @@ -9,15 +9,18 @@ path = "src/lib.rs" [dependencies] axum = { version = "0.8.1", features = ["http2", "json"] } +axum-server = { version = "0.7", features = ["tls-rustls"] } http = "1.2.0" -hyper = "1.5.2" -hyper-rustls = "0.27.5" +hyper = { version = "1.5.2", features = ["server"] } +hyper-rustls = { version = "0.27.5", features = ["http2"] } hyper-util = { version = "0.1.10", features = ["client-legacy"] } futures = "0.3.31" linkup = { path = "../linkup" } rustls = { version = "0.23.21", default-features = false, features = ["ring"] } rustls-native-certs = "0.8.1" thiserror = "2.0.11" -tokio = { version = "1.43.0", features = ["macros", "signal"] } +tokio = { version = "1.43.0", features = ["macros", "signal", "rt-multi-thread"] } tower-http = { version = "0.6.2", features = ["trace"] } tower = "0.5.2" +rcgen = { version = "0.13", features = ["x509-parser"] } +rustls-pemfile = "2.2.0" diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs new file mode 100644 index 00000000..73117dc6 --- /dev/null +++ b/local-server/src/certificates/mod.rs @@ -0,0 +1,214 @@ +mod wildcard_sni_resolver; + +use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; +use rustls::crypto::ring::sign; +use rustls::pki_types::CertificateDer; +use rustls::sign::CertifiedKey; +use std::{ + env, + fs::{self, File}, + io::BufReader, + path::{Path, PathBuf}, + process, +}; + +pub use wildcard_sni_resolver::WildcardSniResolver; + +const LINKUP_CA_COMMON_NAME: &str = "Linkup Local CA"; + +pub fn ca_cert_pem_path(certs_dir: &Path) -> PathBuf { + certs_dir.join("linkup_ca.cert.pem") +} + +pub fn ca_key_pem_path(certs_dir: &Path) -> PathBuf { + certs_dir.join("linkup_ca.key.pem") +} + +#[derive(Debug, thiserror::Error)] +pub enum BuildCertifiedKeyError { + #[error("Failed to read file: {0}")] + FileRead(#[from] std::io::Error), + #[error("File does not contain valid certificate")] + InvalidCertFile, + #[error("File does not contain valid private key")] + InvalidKeyFile, +} + +fn build_certified_key( + cert_path: &Path, + key_path: &Path, +) -> Result { + let mut cert_pem = BufReader::new(File::open(cert_path)?); + let mut key_pem = BufReader::new(File::open(key_path)?); + + let certs = rustls_pemfile::certs(&mut cert_pem) + .filter_map(|cert| cert.ok()) + .collect::>>(); + + if certs.is_empty() { + return Err(BuildCertifiedKeyError::InvalidCertFile); + } + + let key_der = rustls_pemfile::private_key(&mut key_pem) + .map_err(|_| BuildCertifiedKeyError::InvalidKeyFile)? + .ok_or(BuildCertifiedKeyError::InvalidCertFile)?; + + let signing_key = + sign::any_supported_type(&key_der).map_err(|_| BuildCertifiedKeyError::InvalidKeyFile)?; + + Ok(CertifiedKey { + cert: certs, + key: signing_key, + ocsp: None, + }) +} + +pub fn create_domain_cert(certs_dir: &Path, domain: &str) -> (Certificate, KeyPair) { + let cert_pem_str = fs::read_to_string(ca_cert_pem_path(certs_dir)).unwrap(); + let key_pem_str = fs::read_to_string(ca_key_pem_path(certs_dir)).unwrap(); + + let params = CertificateParams::from_ca_cert_pem(&cert_pem_str).unwrap(); + let ca_key = KeyPair::from_pem(&key_pem_str).unwrap(); + let ca_cert = params.self_signed(&ca_key).unwrap(); + + let mut params = CertificateParams::new(vec![domain.to_string()]).unwrap(); + params.distinguished_name = DistinguishedName::new(); + params.distinguished_name.push(DnType::CommonName, domain); + params.is_ca = rcgen::IsCa::NoCa; + + let key_pair = KeyPair::generate().unwrap(); + let cert = params.signed_by(&key_pair, &ca_cert, &ca_key).unwrap(); + + let escaped_domain = domain.replace("*", "wildcard_"); + let cert_path = certs_dir.join(format!("{}.cert.pem", &escaped_domain)); + let key_path = certs_dir.join(format!("{}.key.pem", &escaped_domain)); + fs::write(cert_path, cert.pem()).unwrap(); + fs::write(key_path, key_pair.serialize_pem()).unwrap(); + + (cert, key_pair) +} + +pub fn install_ca_certificate(certs_dir: &Path) { + upsert_ca_cert(certs_dir); + add_ca_to_keychain(certs_dir); +} + +fn upsert_ca_cert(certs_dir: &Path) { + if ca_cert_pem_path(certs_dir).exists() && ca_key_pem_path(certs_dir).exists() { + return; + } + + let mut params = CertificateParams::new(Vec::new()).unwrap(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + + params + .distinguished_name + .push(rcgen::DnType::CommonName, LINKUP_CA_COMMON_NAME); + + let key_pair = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + fs::write(ca_cert_pem_path(certs_dir), cert.pem()).unwrap(); + fs::write(ca_key_pem_path(certs_dir), key_pair.serialize_pem()).unwrap(); +} + +fn add_ca_to_keychain(certs_dir: &Path) { + process::Command::new("sudo") + .arg("security") + .arg("add-trusted-cert") + .arg("-d") + .arg("-r") + .arg("trustRoot") + .arg("-k") + .arg("/Library/Keychains/System.keychain") + .arg(ca_cert_pem_path(certs_dir)) + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .status() + .expect("Failed to add CA to keychain"); +} + +pub fn install_nss() { + if is_nss_installed() { + println!("NSS already installed, skipping installation"); + return; + } + + process::Command::new("brew") + .arg("install") + .arg("nss") + .status() + .expect("Failed to install NSS"); +} + +pub fn add_ca_to_nss(certs_dir: &Path) { + if !is_nss_installed() { + println!("NSS not found, skipping CA installation"); + return; + } + + let home = env::var("HOME").expect("Failed to get HOME env var"); + match fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) { + Ok(dir) => { + let profiles_dbs = dir + .filter_map(|entry| { + let entry = entry.expect("Failed to read Firefox profile dir entry entry"); + let path = entry.path(); + if path.is_dir() { + if path.join("cert9.db").exists() { + Some(format!("{}{}", "sql:", path.to_str().unwrap())) + } else if path.join("cert8.db").exists() { + Some(format!("{}{}", "dmb:", path.to_str().unwrap())) + } else { + None + } + } else { + None + } + }) + .collect::>(); + + for profile in profiles_dbs { + let result = process::Command::new("certutil") + .arg("-A") + .arg("-d") + .arg(&profile) + .arg("-t") + .arg("C,,") + .arg("-n") + .arg(LINKUP_CA_COMMON_NAME) + .arg("-i") + .arg(ca_cert_pem_path(certs_dir)) + .status(); + + if let Err(e) = result { + eprintln!("certutil failed to run for profile {}: {}", profile, e); + } + } + } + Err(error) => { + eprintln!("Failed to load Firefox profiles: {}", error); + } + } +} + +fn is_nss_installed() -> bool { + let res = process::Command::new("which") + .args(["certutil"]) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .stdin(process::Stdio::null()) + .status(); + + match res { + Ok(status) => status.success(), + Err(e) => { + eprintln!("Failed to check if certutil is installed: {}", e); + false + } + } +} diff --git a/local-server/src/certificates/wildcard_sni_resolver.rs b/local-server/src/certificates/wildcard_sni_resolver.rs new file mode 100644 index 00000000..b2947733 --- /dev/null +++ b/local-server/src/certificates/wildcard_sni_resolver.rs @@ -0,0 +1,92 @@ +use crate::certificates::build_certified_key; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, thiserror::Error)] +pub enum WildcardSniResolverError { + #[error("Failed to read certs directory: {0}")] + ReadDir(#[from] std::io::Error), + + #[error("Failed to get file name")] + FileName, + + #[error("Error building certified key: {0}")] + LoadCert(#[from] crate::certificates::BuildCertifiedKeyError), +} + +#[derive(Debug)] +pub struct WildcardSniResolver { + certs: RwLock>>, +} + +impl WildcardSniResolver { + fn new() -> Self { + Self { + certs: RwLock::new(HashMap::new()), + } + } + + pub fn load_dir(certs_dir: &Path) -> Result { + let resolver = WildcardSniResolver::new(); + + let entries = fs::read_dir(certs_dir)?; + + for entry in entries.flatten() { + let path = entry.path(); + if let Some(file_name) = path.file_name() { + let file_name = file_name.to_string_lossy(); + + if file_name.contains(".cert.pem") && !path.starts_with("linkup_ca") { + let domain_name = file_name.replace(".cert.pem", "").replace("wildcard_", "*"); + let key_path = + PathBuf::from(path.to_string_lossy().replace(".cert.pem", ".key.pem")); + + if key_path.exists() { + match build_certified_key(&path, &key_path) { + Ok(certified_key) => { + resolver.add_cert(&domain_name, certified_key); + } + Err(e) => { + eprintln!("Error loading cert/key for {domain_name}: {e}"); + } + } + } + } + } + } + + Ok(resolver) + } + + fn add_cert(&self, domain: &str, cert: CertifiedKey) { + let mut certs = self.certs.write().unwrap(); + certs.insert(domain.to_string(), Arc::new(cert)); + } +} + +impl ResolvesServerCert for WildcardSniResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + if let Some(server_name) = client_hello.server_name() { + let certs = self.certs.read().unwrap(); + + if let Some(cert) = certs.get(server_name) { + return Some(cert.clone()); + } + + let parts: Vec<&str> = server_name.split('.').collect(); + + for i in 0..parts.len() { + let wildcard_domain = format!("*.{}", parts[i..].join(".")); + if let Some(cert) = certs.get(&wildcard_domain) { + return Some(cert.clone()); + } + } + } + + None + } +} diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index f2d97ba4..5eace137 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -6,24 +6,27 @@ use axum::{ routing::{any, get, post}, Extension, Router, }; +use axum_server::tls_rustls::RustlsConfig; use http::{header::HeaderMap, Uri}; use hyper_rustls::HttpsConnector; use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, rt::{TokioExecutor, TokioIo}, }; - use linkup::{ allow_all_cors, get_additional_headers, get_target_service, MemoryStringStore, NameKind, Session, SessionAllocator, TargetService, UpdateSessionRequest, }; +use rustls::ServerConfig; +use std::net::SocketAddr; +use std::{path::Path, sync::Arc}; use tokio::signal; use tower::ServiceBuilder; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; -type HttpsClient = Client, Body>; +pub mod certificates; -const LINKUP_LOCALSERVER_PORT: u16 = 9066; +type HttpsClient = Client, Body>; #[derive(Debug)] struct ApiError { @@ -50,8 +53,7 @@ impl IntoResponse for ApiError { } } -pub fn linkup_router() -> Router { - let config_store = MemoryStringStore::default(); +pub fn linkup_router(config_store: MemoryStringStore) -> Router { let client = https_client(); Router::new() @@ -71,13 +73,43 @@ pub fn linkup_router() -> Router { ) } -pub async fn start_server() -> std::io::Result<()> { - let app = linkup_router(); +pub async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let sni = match certificates::WildcardSniResolver::load_dir(certs_dir) { + Ok(sni) => sni, + Err(error) => { + eprintln!( + "Failed to load certificates from {:?} into SNI: {}", + certs_dir, error + ); + return; + } + }; + + let mut server_config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(sni)); + server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + + let app = linkup_router(config_store); - let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", LINKUP_LOCALSERVER_PORT)) + let addr = SocketAddr::from(([127, 0, 0, 1], 443)); + println!("listening on {}", &addr); + + axum_server::bind_rustls(addr, RustlsConfig::from_config(Arc::new(server_config))) + .serve(app.into_make_service()) .await - .unwrap(); - println!("listening on {}", listener.local_addr().unwrap()); + .expect("failed to start HTTPS server"); +} + +pub async fn start_server_http(config_store: MemoryStringStore) -> std::io::Result<()> { + let app = linkup_router(config_store); + + let addr = SocketAddr::from(([127, 0, 0, 1], 80)); + println!("listening on {}", &addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; @@ -87,7 +119,7 @@ pub async fn start_server() -> std::io::Result<()> { #[tokio::main] pub async fn local_linkup_main() -> std::io::Result<()> { - start_server().await + start_server_http(MemoryStringStore::default()).await } async fn linkup_request_handler( @@ -98,7 +130,8 @@ async fn linkup_request_handler( let sessions = SessionAllocator::new(&store); let headers: linkup::HeaderMap = req.headers().into(); - let url = format!("http://localhost:{}{}", LINKUP_LOCALSERVER_PORT, req.uri()); + let url = req.uri().to_string(); + let (session_name, config) = match sessions.get_request_session(&url, &headers).await { Ok(session) => session, Err(_) => { @@ -106,7 +139,7 @@ async fn linkup_request_handler( "Linkup was unable to determine the session origin of the request. Ensure that your request includes a valid session identifier in the referer or tracestate headers. - Local Server".to_string(), StatusCode::UNPROCESSABLE_ENTITY, ) - .into_response() + .into_response() } }; @@ -117,7 +150,7 @@ async fn linkup_request_handler( "The request belonged to a session, but there was no target for the request. Check that the routing rules in your linkup config have a match for this request. - Local Server".to_string(), StatusCode::NOT_FOUND, ) - .into_response() + .into_response() } }; @@ -340,6 +373,7 @@ fn https_client() -> HttpsClient { .with_tls_config(tls) .https_or_http() .enable_http1() + .enable_http2() .build(); Client::builder(TokioExecutor::new()).build(https) From 987274aa463c76bfa3fea6e7703607fa59beb7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 11:14:23 +0000 Subject: [PATCH 05/17] refactor: drop target_os checks on local-dns For now it should only be available on macos --- linkup-cli/src/commands/local_dns.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index d5e7dc21..3ae1cda5 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -111,8 +111,6 @@ fn install_resolvers(resolve_domains: &[String]) -> Result<()> { } flush_dns_cache()?; - - #[cfg(target_os = "macos")] kill_dns_responder()?; Ok(()) @@ -135,8 +133,6 @@ fn uninstall_resolvers(resolve_domains: &[String]) -> Result<()> { } flush_dns_cache()?; - - #[cfg(target_os = "macos")] kill_dns_responder()?; Ok(()) @@ -160,15 +156,6 @@ pub fn list_resolvers() -> std::result::Result, std::io::Error> { } fn flush_dns_cache() -> Result<()> { - #[cfg(target_os = "linux")] - let status_flush = Command::new("resolvectl") - .args(["flush-caches"]) - .status() - .map_err(|_err| { - CliError::LocalDNSInstall("Failed to run resolvectl flush-caches".into()) - })?; - - #[cfg(target_os = "macos")] let status_flush = Command::new("dscacheutil") .args(["-flushcache"]) .status() @@ -183,7 +170,6 @@ fn flush_dns_cache() -> Result<()> { Ok(()) } -#[cfg(target_os = "macos")] fn kill_dns_responder() -> Result<()> { let status_kill_responder = Command::new("sudo") .args(["killall", "-HUP", "mDNSResponder"]) From f508600ec5beb5394397775c1dfe7db2d9e7e3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 11:17:19 +0000 Subject: [PATCH 06/17] feat: add certificates generation to local-dns install --- linkup-cli/src/commands/local_dns.rs | 29 +++++++++++++++++++++++++--- local-server/src/certificates/mod.rs | 20 ++++++++++--------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 3ae1cda5..2dba0168 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -3,13 +3,13 @@ use std::{ process::{Command, Stdio}, }; -use clap::Subcommand; - use crate::{ - commands, is_sudo, + commands, is_sudo, linkup_certs_dir_path, local_config::{config_path, get_config}, sudo_su, CliError, Result, }; +use clap::Subcommand; +use linkup_local_server::certificates::setup_self_signed_certificates; #[derive(clap::Args)] pub struct Args { @@ -48,6 +48,20 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_resolver_dir()?; install_resolvers(&input_config.top_level_domains())?; + ensure_certs_dir()?; + let certs_dir = linkup_certs_dir_path(); + setup_self_signed_certificates(&certs_dir); + + for domain in input_config + .domains + .iter() + .map(|storable_domain| storable_domain.domain.clone()) + .collect::>() + { + linkup_local_server::certificates::create_domain_cert(&certs_dir, &format!("*.{}", domain)); + println!("Created certificate for {}", domain); + } + Ok(()) } @@ -84,6 +98,15 @@ fn ensure_resolver_dir() -> Result<()> { Ok(()) } +fn ensure_certs_dir() -> Result<()> { + let path = linkup_certs_dir_path(); + if !path.exists() { + fs::create_dir_all(path)?; + } + + Ok(()) +} + fn install_resolvers(resolve_domains: &[String]) -> Result<()> { for domain in resolve_domains.iter() { let cmd_str = format!( diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs index 73117dc6..95009b87 100644 --- a/local-server/src/certificates/mod.rs +++ b/local-server/src/certificates/mod.rs @@ -16,11 +16,11 @@ pub use wildcard_sni_resolver::WildcardSniResolver; const LINKUP_CA_COMMON_NAME: &str = "Linkup Local CA"; -pub fn ca_cert_pem_path(certs_dir: &Path) -> PathBuf { +fn ca_cert_pem_path(certs_dir: &Path) -> PathBuf { certs_dir.join("linkup_ca.cert.pem") } -pub fn ca_key_pem_path(certs_dir: &Path) -> PathBuf { +fn ca_key_pem_path(certs_dir: &Path) -> PathBuf { certs_dir.join("linkup_ca.key.pem") } @@ -63,6 +63,13 @@ fn build_certified_key( }) } +pub fn setup_self_signed_certificates(certs_dir: &Path) { + upsert_ca_cert(certs_dir); + add_ca_to_keychain(certs_dir); + install_nss(); + add_ca_to_nss(certs_dir); +} + pub fn create_domain_cert(certs_dir: &Path, domain: &str) -> (Certificate, KeyPair) { let cert_pem_str = fs::read_to_string(ca_cert_pem_path(certs_dir)).unwrap(); let key_pem_str = fs::read_to_string(ca_key_pem_path(certs_dir)).unwrap(); @@ -88,11 +95,6 @@ pub fn create_domain_cert(certs_dir: &Path, domain: &str) -> (Certificate, KeyPa (cert, key_pair) } -pub fn install_ca_certificate(certs_dir: &Path) { - upsert_ca_cert(certs_dir); - add_ca_to_keychain(certs_dir); -} - fn upsert_ca_cert(certs_dir: &Path) { if ca_cert_pem_path(certs_dir).exists() && ca_key_pem_path(certs_dir).exists() { return; @@ -132,7 +134,7 @@ fn add_ca_to_keychain(certs_dir: &Path) { .expect("Failed to add CA to keychain"); } -pub fn install_nss() { +fn install_nss() { if is_nss_installed() { println!("NSS already installed, skipping installation"); return; @@ -145,7 +147,7 @@ pub fn install_nss() { .expect("Failed to install NSS"); } -pub fn add_ca_to_nss(certs_dir: &Path) { +fn add_ca_to_nss(certs_dir: &Path) { if !is_nss_installed() { println!("NSS not found, skipping CA installation"); return; From 2de4c8b0ece46e8463a10a8a230487fa75ac8826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 11:27:12 +0000 Subject: [PATCH 07/17] test: fix server-tests helper --- server-tests/tests/helpers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server-tests/tests/helpers.rs b/server-tests/tests/helpers.rs index 6a498490..b44d40a0 100644 --- a/server-tests/tests/helpers.rs +++ b/server-tests/tests/helpers.rs @@ -1,6 +1,6 @@ use std::process::Command; -use linkup::{StorableDomain, StorableService, UpdateSessionRequest}; +use linkup::{MemoryStringStore, StorableDomain, StorableService, UpdateSessionRequest}; use linkup_local_server::linkup_router; use reqwest::Url; use tokio::net::TcpListener; @@ -14,7 +14,7 @@ pub enum ServerKind { pub async fn setup_server(kind: ServerKind) -> String { match kind { ServerKind::Local => { - let app = linkup_router(); + let app = linkup_router(MemoryStringStore::default()); // Bind to a random port assigned by the OS let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); From 202cb4581e127d2a4bb5c048247a4956208cbd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 11:28:44 +0000 Subject: [PATCH 08/17] fix: add sudo where it is necessary right now --- linkup-cli/src/commands/start.rs | 15 +++++++++------ linkup-cli/src/commands/stop.rs | 10 +++++++++- linkup-cli/src/services/local_server.rs | 3 ++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 088de0fb..19682a99 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -11,12 +11,7 @@ use std::{ use colored::Colorize; use crossterm::{cursor, ExecutableCommand}; -use crate::{ - commands::status::{format_state_domains, SessionStatus}, - env_files::write_to_env_file, - local_config::{config_path, config_to_state, get_config}, - services::{self, BackgroundService}, -}; +use crate::{commands::status::{format_state_domains, SessionStatus}, env_files::write_to_env_file, is_sudo, local_config::{config_path, config_to_state, get_config}, services::{self, BackgroundService}, sudo_su}; use crate::{local_config::LocalState, CliError}; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -76,6 +71,14 @@ pub async fn start( // send the message to the display thread to stop and we join it. let mut exit_error: Option> = None; + // TODO(augustoccesar)[2025-03-11]: Since we are binding now on 80 and 443 ourselves, we need + // to get sudo permission. Ideally this wouldn't be necessary, so we should take a look if/how + // we can avoid needing it. Caddy was able to bind on them without sudo (at least on macos), + // so there could be a way. + if !is_sudo() { + sudo_su()?; + } + match local_server .run_with_progress(&mut state, status_update_channel.0.clone()) .await diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 4d3a4944..06085ee5 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use crate::env_files::clear_env_file; use crate::local_config::LocalState; -use crate::{services, CliError}; +use crate::{is_sudo, services, sudo_su, CliError}; #[derive(clap::Args)] pub struct Args {} @@ -29,6 +29,14 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<(), CliError> { } } + // TODO(augustoccesar)[2025-03-11]: Since we are binding now on 80 and 443 ourselves, we need + // to get sudo permission. Ideally this wouldn't be necessary, so we should take a look if/how + // we can avoid needing it. Caddy was able to bind on them without sudo (at least on macos), + // so there could be a way. + if !is_sudo() { + sudo_su()?; + } + services::LocalServer::new().stop(); services::CloudflareTunnel::new().stop(); #[cfg(feature = "localdns")] diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index c60b62d9..1fbd28a9 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -59,8 +59,9 @@ impl LocalServer { // When running with cargo (e.g. `cargo run -- start`), we should start the server also with cargo. let mut command = if env::var("CARGO").is_ok() { - let mut cmd = process::Command::new("cargo"); + let mut cmd = process::Command::new("sudo"); cmd.args([ + "cargo", "run", "--", "server", From efaa581d073372a7a6959c4742a4d1f952600d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 14:04:17 +0000 Subject: [PATCH 09/17] refactor: filter out local-dns by target os --- linkup-cli/Cargo.toml | 1 - linkup-cli/build.rs | 10 ---------- linkup-cli/src/commands/health.rs | 20 ++++++++++---------- linkup-cli/src/commands/mod.rs | 4 ++-- linkup-cli/src/commands/server.rs | 9 ++++++--- linkup-cli/src/commands/start.rs | 6 +++--- linkup-cli/src/commands/stop.rs | 2 +- linkup-cli/src/main.rs | 4 ++-- linkup-cli/src/services/mod.rs | 4 ++-- 9 files changed, 26 insertions(+), 34 deletions(-) diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index c24d4532..c5224d8f 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -50,4 +50,3 @@ mockito = "1.6.1" [features] default = [] -localdns = [] diff --git a/linkup-cli/build.rs b/linkup-cli/build.rs index 217847f8..7d10252e 100644 --- a/linkup-cli/build.rs +++ b/linkup-cli/build.rs @@ -4,16 +4,6 @@ use std::path::Path; use std::process::Command; fn main() { - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - - if target_os == "macos" { - println!("cargo:rustc-cfg=feature=\"localdns\""); - } - - if target_os == "linux" && env::var("CARGO_FEATURE_LOCALDNS").is_ok() { - panic!("The `localdns` feature is not supported on Linux"); - } - println!("cargo::rerun-if-changed=../worker/src"); let out_dir = env::var("OUT_DIR").expect("OUT_DIR to be set"); diff --git a/linkup-cli/src/commands/health.rs b/linkup-cli/src/commands/health.rs index a998a5e6..174959c9 100644 --- a/linkup-cli/src/commands/health.rs +++ b/linkup-cli/src/commands/health.rs @@ -10,7 +10,7 @@ use serde::Serialize; use crate::{linkup_dir_path, local_config::LocalState, services, CliError}; -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] use super::local_dns; #[derive(clap::Args)] @@ -81,7 +81,7 @@ struct OrphanProcess { #[derive(Debug, Serialize)] struct BackgroudServices { linkup_server: BackgroundServiceHealth, - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] dnsmasq: BackgroundServiceHealth, cloudflared: BackgroundServiceHealth, possible_orphan_processes: Vec, @@ -107,7 +107,7 @@ impl BackgroudServices { None => BackgroundServiceHealth::Stopped, }; - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] let dnsmasq = if services::is_dnsmasq_installed() { match services::Dnsmasq::new().running_pid() { Some(pid) => { @@ -136,7 +136,7 @@ impl BackgroudServices { Self { linkup_server, - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] dnsmasq, cloudflared, possible_orphan_processes: find_potential_orphan_processes(managed_pids), @@ -197,13 +197,13 @@ impl Linkup { } } -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] #[derive(Debug, Serialize)] struct LocalDNS { resolvers: Vec, } -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] impl LocalDNS { fn load() -> Result { Ok(Self { @@ -218,7 +218,7 @@ struct Health { session: Option, background_services: BackgroudServices, linkup: Linkup, - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] local_dns: LocalDNS, } @@ -238,7 +238,7 @@ impl Health { session, background_services: BackgroudServices::load(), linkup: Linkup::load()?, - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] local_dns: LocalDNS::load()?, }) } @@ -278,7 +278,7 @@ impl Display for Health { BackgroundServiceHealth::Running(pid) => writeln!(f, "{} ({})", "RUNNING".blue(), pid)?, } - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] { write!(f, " - dnsmasq ")?; match &self.background_services.dnsmasq { @@ -317,7 +317,7 @@ impl Display for Health { } } - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] { write!(f, "{}", "Local DNS resolvers:".bold().italic())?; if self.local_dns.resolvers.is_empty() { diff --git a/linkup-cli/src/commands/mod.rs b/linkup-cli/src/commands/mod.rs index 7d73affa..09de8067 100644 --- a/linkup-cli/src/commands/mod.rs +++ b/linkup-cli/src/commands/mod.rs @@ -2,7 +2,7 @@ pub mod completion; pub mod deploy; pub mod health; pub mod local; -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] pub mod local_dns; pub mod preview; pub mod remote; @@ -19,7 +19,7 @@ pub use {deploy::deploy, deploy::DeployArgs}; pub use {deploy::destroy, deploy::DestroyArgs}; pub use {health::health, health::Args as HealthArgs}; pub use {local::local, local::Args as LocalArgs}; -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] pub use {local_dns::local_dns, local_dns::Args as LocalDnsArgs}; pub use {preview::preview, preview::Args as PreviewArgs}; pub use {remote::remote, remote::Args as RemoteArgs}; diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index 6aed3f5c..e4bfd331 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,7 +1,7 @@ use crate::CliError; use linkup::MemoryStringStore; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use tokio::select; #[derive(clap::Args)] @@ -10,6 +10,7 @@ pub struct Args { pidfile: String, } +#[cfg_attr(not(target_os = "macos"), allow(unused_variables))] pub async fn server(args: &Args, certs_dir: &Path) -> Result<(), CliError> { let pid = std::process::id(); fs::write(&args.pidfile, pid.to_string())?; @@ -23,8 +24,10 @@ pub async fn server(args: &Args, certs_dir: &Path) -> Result<(), CliError> { .unwrap(); }); - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] let handler_https = { + use std::path::PathBuf; + let https_config_store = config_store.clone(); let https_certs_dir = PathBuf::from(certs_dir); @@ -33,7 +36,7 @@ pub async fn server(args: &Args, certs_dir: &Path) -> Result<(), CliError> { })) }; - #[cfg(not(feature = "localdns"))] + #[cfg(not(target_os = "macos"))] let handler_https: Option> = None; match handler_https { diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 19682a99..d2c799d0 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -44,7 +44,7 @@ pub async fn start( let local_server = services::LocalServer::new(); let cloudflare_tunnel = services::CloudflareTunnel::new(); - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] let dnsmasq = services::Dnsmasq::new(); let mut display_thread: Option> = None; @@ -58,7 +58,7 @@ pub async fn start( &[ services::LocalServer::NAME, services::CloudflareTunnel::NAME, - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] services::Dnsmasq::NAME, ], status_update_channel.1, @@ -97,7 +97,7 @@ pub async fn start( } } - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] if exit_error.is_none() { match dnsmasq .run_with_progress(&mut state, status_update_channel.0.clone()) diff --git a/linkup-cli/src/commands/stop.rs b/linkup-cli/src/commands/stop.rs index 06085ee5..c0daf2de 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -39,7 +39,7 @@ pub fn stop(_args: &Args, clear_env: bool) -> Result<(), CliError> { services::LocalServer::new().stop(); services::CloudflareTunnel::new().stop(); - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] services::Dnsmasq::new().stop(); println!("Stopped linkup"); diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index a1897dff..7a4aa776 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -236,7 +236,7 @@ enum Commands { #[clap(about = "View linkup component and service status")] Status(commands::StatusArgs), - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] #[clap(about = "Speed up your local environment by routing traffic locally when possible")] LocalDNS(commands::LocalDnsArgs), @@ -292,7 +292,7 @@ async fn main() -> Result<()> { Commands::Local(args) => commands::local(args).await, Commands::Remote(args) => commands::remote(args).await, Commands::Status(args) => commands::status(args), - #[cfg(feature = "localdns")] + #[cfg(target_os = "macos")] Commands::LocalDNS(args) => commands::local_dns(args, &cli.config).await, Commands::Completion(args) => commands::completion(args), Commands::Preview(args) => commands::preview(args, &cli.config).await, diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 361e09fe..85374a00 100644 --- a/linkup-cli/src/services/mod.rs +++ b/linkup-cli/src/services/mod.rs @@ -6,7 +6,7 @@ use sysinfo::{get_current_pid, ProcessRefreshKind, RefreshKind, System}; use thiserror::Error; mod cloudflare_tunnel; -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] mod dnsmasq; mod local_server; @@ -16,7 +16,7 @@ pub use { cloudflare_tunnel::is_installed as is_cloudflared_installed, cloudflare_tunnel::CloudflareTunnel, }; -#[cfg(feature = "localdns")] +#[cfg(target_os = "macos")] pub use {dnsmasq::is_installed as is_dnsmasq_installed, dnsmasq::Dnsmasq}; use crate::local_config::LocalState; From 94881f97dd52476f973b521e84f132b6077cb8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 14:48:11 +0000 Subject: [PATCH 10/17] feat: use ring instead of aws-lc-rs --- Cargo.lock | 3 --- local-server/Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8010ea62..f34bc27b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1242,9 +1242,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "log", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2486,7 +2484,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", diff --git a/local-server/Cargo.toml b/local-server/Cargo.toml index a02f042f..a49499c9 100644 --- a/local-server/Cargo.toml +++ b/local-server/Cargo.toml @@ -12,7 +12,7 @@ axum = { version = "0.8.1", features = ["http2", "json"] } axum-server = { version = "0.7", features = ["tls-rustls"] } http = "1.2.0" hyper = { version = "1.5.2", features = ["server"] } -hyper-rustls = { version = "0.27.5", features = ["http2"] } +hyper-rustls = { version = "0.27.5", default-features = false, features = ["http2", "ring"] } hyper-util = { version = "0.1.10", features = ["client-legacy"] } futures = "0.3.31" linkup = { path = "../linkup" } From 2c74466a648f15bc3b0522d4f1fbb21ee495f9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 14:49:26 +0000 Subject: [PATCH 11/17] ci: trigger tests for CI for linux and mac --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1375db9..2a67eb69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,10 @@ name: CI jobs: check: name: Check and Clippy - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] env: RUSTFLAGS: -D warnings steps: @@ -30,8 +33,11 @@ jobs: - run: cargo fmt --all --check test: - name: Test Suite - runs-on: ubuntu-latest + name: Test Suite (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable From 3b50b9e717aa0e978fef51f91ccab290402d6d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 15:04:11 +0000 Subject: [PATCH 12/17] lint: fix formatting --- linkup-cli/src/commands/start.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index d2c799d0..d1e7010b 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -11,7 +11,14 @@ use std::{ use colored::Colorize; use crossterm::{cursor, ExecutableCommand}; -use crate::{commands::status::{format_state_domains, SessionStatus}, env_files::write_to_env_file, is_sudo, local_config::{config_path, config_to_state, get_config}, services::{self, BackgroundService}, sudo_su}; +use crate::{ + commands::status::{format_state_domains, SessionStatus}, + env_files::write_to_env_file, + is_sudo, + local_config::{config_path, config_to_state, get_config}, + services::{self, BackgroundService}, + sudo_su, +}; use crate::{local_config::LocalState, CliError}; const LOADING_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; From c0cecba9dc112f2040c0f81e191ed0217d3c95c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 11 Mar 2025 15:06:00 +0000 Subject: [PATCH 13/17] fix: allow some unused on not macos --- linkup-cli/src/local_config.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linkup-cli/src/local_config.rs b/linkup-cli/src/local_config.rs index 1e0b8c28..9581e8e6 100644 --- a/linkup-cli/src/local_config.rs +++ b/linkup-cli/src/local_config.rs @@ -81,6 +81,7 @@ impl LocalState { } } + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub fn domain_strings(&self) -> Vec { self.domains .iter() @@ -134,6 +135,7 @@ pub struct YamlLocalConfig { } impl YamlLocalConfig { + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub fn top_level_domains(&self) -> Vec { self.domains .iter() From c880cf237eb4c8486de0cb7332ef356a7fd31168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Wed, 12 Mar 2025 09:38:31 +0000 Subject: [PATCH 14/17] fix: build uri if scheme is not available --- local-server/src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 5eace137..c4bd2773 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -130,7 +130,18 @@ async fn linkup_request_handler( let sessions = SessionAllocator::new(&store); let headers: linkup::HeaderMap = req.headers().into(); - let url = req.uri().to_string(); + let url = if req.uri().scheme().is_some() { + req.uri().to_string() + } else { + format!( + "http://{}{}", + req.headers() + .get(http::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"), + req.uri() + ) + }; let (session_name, config) = match sessions.get_request_session(&url, &headers).await { Ok(session) => session, From 506d63ce656328a9cadded1774f2298014ce458a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 13 Mar 2025 11:25:48 +0000 Subject: [PATCH 15/17] refactor: move cert creation to the certificates setup --- linkup-cli/src/commands/local_dns.rs | 28 +++++++++------------------- local-server/src/certificates/mod.rs | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 2dba0168..1a265aed 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -48,19 +48,18 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_resolver_dir()?; install_resolvers(&input_config.top_level_domains())?; - ensure_certs_dir()?; - let certs_dir = linkup_certs_dir_path(); - setup_self_signed_certificates(&certs_dir); - - for domain in input_config + let domains = input_config .domains .iter() .map(|storable_domain| storable_domain.domain.clone()) - .collect::>() - { - linkup_local_server::certificates::create_domain_cert(&certs_dir, &format!("*.{}", domain)); - println!("Created certificate for {}", domain); - } + .collect::>(); + + setup_self_signed_certificates(&linkup_certs_dir_path(), &domains).map_err(|error| { + CliError::LocalDNSInstall(format!( + "Failed to setup self signed certificates: {}", + error.to_string() + )) + })?; Ok(()) } @@ -98,15 +97,6 @@ fn ensure_resolver_dir() -> Result<()> { Ok(()) } -fn ensure_certs_dir() -> Result<()> { - let path = linkup_certs_dir_path(); - if !path.exists() { - fs::create_dir_all(path)?; - } - - Ok(()) -} - fn install_resolvers(resolve_domains: &[String]) -> Result<()> { for domain in resolve_domains.iter() { let cmd_str = format!( diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs index 95009b87..2faab45f 100644 --- a/local-server/src/certificates/mod.rs +++ b/local-server/src/certificates/mod.rs @@ -63,11 +63,32 @@ fn build_certified_key( }) } -pub fn setup_self_signed_certificates(certs_dir: &Path) { +#[derive(Debug, thiserror::Error)] +pub enum SetupError { + #[error("Failed to create certificates directory '{0}': {1}")] + CreateCertsDir(PathBuf, String), +} + +pub fn setup_self_signed_certificates( + certs_dir: &Path, + domains: &[String], +) -> Result<(), SetupError> { + if !certs_dir.exists() { + fs::create_dir_all(certs_dir).map_err(|error| { + SetupError::CreateCertsDir(certs_dir.to_path_buf(), error.to_string()) + })?; + } + upsert_ca_cert(certs_dir); add_ca_to_keychain(certs_dir); install_nss(); add_ca_to_nss(certs_dir); + + for domain in domains { + create_domain_cert(&certs_dir, &format!("*.{}", domain)); + } + + Ok(()) } pub fn create_domain_cert(certs_dir: &Path, domain: &str) -> (Certificate, KeyPair) { From 0987a2700cbe4657db8660894e2880f79e31e81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 13 Mar 2025 11:33:47 +0000 Subject: [PATCH 16/17] feat: only manage NSS if found Firefox profiles --- local-server/src/certificates/mod.rs | 95 +++++++++++++++------------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs index 2faab45f..e1e8ce05 100644 --- a/local-server/src/certificates/mod.rs +++ b/local-server/src/certificates/mod.rs @@ -81,8 +81,12 @@ pub fn setup_self_signed_certificates( upsert_ca_cert(certs_dir); add_ca_to_keychain(certs_dir); - install_nss(); - add_ca_to_nss(certs_dir); + + let ff_cert_storages = firefox_profiles_cert_storages(); + if !ff_cert_storages.is_empty() { + install_nss(); + add_ca_to_nss(certs_dir, &ff_cert_storages); + } for domain in domains { create_domain_cert(&certs_dir, &format!("*.{}", domain)); @@ -155,6 +159,35 @@ fn add_ca_to_keychain(certs_dir: &Path) { .expect("Failed to add CA to keychain"); } +fn firefox_profiles_cert_storages() -> Vec { + let home = env::var("HOME").expect("Failed to get HOME env var"); + + match fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) { + Ok(dir) => dir + .filter_map(|entry| { + let entry = entry.expect("Failed to read Firefox profile dir entry entry"); + let path = entry.path(); + if path.is_dir() { + if path.join("cert9.db").exists() { + Some(format!("{}{}", "sql:", path.to_str().unwrap())) + } else if path.join("cert8.db").exists() { + Some(format!("{}{}", "dmb:", path.to_str().unwrap())) + } else { + None + } + } else { + None + } + }) + .collect::>(), + Err(error) => { + eprintln!("Failed to load Firefox profiles: {}", error); + + Vec::new() + } + } +} + fn install_nss() { if is_nss_installed() { println!("NSS already installed, skipping installation"); @@ -168,53 +201,27 @@ fn install_nss() { .expect("Failed to install NSS"); } -fn add_ca_to_nss(certs_dir: &Path) { +fn add_ca_to_nss(certs_dir: &Path, cert_storages: &[String]) { if !is_nss_installed() { println!("NSS not found, skipping CA installation"); return; } - let home = env::var("HOME").expect("Failed to get HOME env var"); - match fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) { - Ok(dir) => { - let profiles_dbs = dir - .filter_map(|entry| { - let entry = entry.expect("Failed to read Firefox profile dir entry entry"); - let path = entry.path(); - if path.is_dir() { - if path.join("cert9.db").exists() { - Some(format!("{}{}", "sql:", path.to_str().unwrap())) - } else if path.join("cert8.db").exists() { - Some(format!("{}{}", "dmb:", path.to_str().unwrap())) - } else { - None - } - } else { - None - } - }) - .collect::>(); - - for profile in profiles_dbs { - let result = process::Command::new("certutil") - .arg("-A") - .arg("-d") - .arg(&profile) - .arg("-t") - .arg("C,,") - .arg("-n") - .arg(LINKUP_CA_COMMON_NAME) - .arg("-i") - .arg(ca_cert_pem_path(certs_dir)) - .status(); - - if let Err(e) = result { - eprintln!("certutil failed to run for profile {}: {}", profile, e); - } - } - } - Err(error) => { - eprintln!("Failed to load Firefox profiles: {}", error); + for cert_storage in cert_storages { + let result = process::Command::new("certutil") + .arg("-A") + .arg("-d") + .arg(&cert_storage) + .arg("-t") + .arg("C,,") + .arg("-n") + .arg(LINKUP_CA_COMMON_NAME) + .arg("-i") + .arg(ca_cert_pem_path(certs_dir)) + .status(); + + if let Err(e) = result { + eprintln!("certutil failed to run for profile {}: {}", cert_storage, e); } } } From eb6a469f4ea49ca51b8b74e550c897676e6f7c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Tue, 18 Mar 2025 09:11:00 +0100 Subject: [PATCH 17/17] lint: fix Clippy offenses --- linkup-cli/src/commands/local_dns.rs | 2 +- local-server/src/certificates/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 1a265aed..d70c55a8 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -57,7 +57,7 @@ pub async fn install(config_arg: &Option) -> Result<()> { setup_self_signed_certificates(&linkup_certs_dir_path(), &domains).map_err(|error| { CliError::LocalDNSInstall(format!( "Failed to setup self signed certificates: {}", - error.to_string() + error )) })?; diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs index e1e8ce05..f9cf1677 100644 --- a/local-server/src/certificates/mod.rs +++ b/local-server/src/certificates/mod.rs @@ -89,7 +89,7 @@ pub fn setup_self_signed_certificates( } for domain in domains { - create_domain_cert(&certs_dir, &format!("*.{}", domain)); + create_domain_cert(certs_dir, &format!("*.{}", domain)); } Ok(()) @@ -211,7 +211,7 @@ fn add_ca_to_nss(certs_dir: &Path, cert_storages: &[String]) { let result = process::Command::new("certutil") .arg("-A") .arg("-d") - .arg(&cert_storage) + .arg(cert_storage) .arg("-t") .arg("C,,") .arg("-n")