From c22ac786ddf37dfb7a28294a156bf9a65be0a754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Wed, 5 Mar 2025 13:29:15 +0100 Subject: [PATCH 1/7] wip --- .github/workflows/caddy.yml | 99 ------- .github/workflows/release.yml | 19 -- Cargo.lock | 198 +++++++++++++- linkup-cli/Cargo.toml | 3 + linkup-cli/src/certificates.rs | 102 ++++++++ linkup-cli/src/commands/health.rs | 21 -- linkup-cli/src/commands/local_dns.rs | 28 +- linkup-cli/src/commands/server.rs | 32 ++- linkup-cli/src/commands/start.rs | 31 --- linkup-cli/src/commands/stop.rs | 1 - linkup-cli/src/commands/update.rs | 12 +- linkup-cli/src/main.rs | 11 +- linkup-cli/src/release.rs | 9 - linkup-cli/src/services/caddy.rs | 332 ------------------------ linkup-cli/src/services/dnsmasq.rs | 6 +- linkup-cli/src/services/local_server.rs | 17 +- linkup-cli/src/services/mod.rs | 2 - local-server/Cargo.toml | 3 +- local-server/src/lib.rs | 39 ++- server-tests/tests/helpers.rs | 4 +- 20 files changed, 396 insertions(+), 573 deletions(-) delete mode 100644 .github/workflows/caddy.yml create mode 100644 linkup-cli/src/certificates.rs 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/.github/workflows/release.yml b/.github/workflows/release.yml index 8b84e827..f5c62c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,22 +55,18 @@ jobs: display_os: linux rust_target: x86_64-unknown-linux-gnu arch: x86_64 - goarch: amd64 - runner: depot-ubuntu-22.04-arm-8 display_os: linux rust_target: aarch64-unknown-linux-gnu arch: aarch64 - goarch: arm64 - runner: depot-macos-14 display_os: darwin rust_target: x86_64-apple-darwin arch: x86_64 - goarch: amd64 - runner: depot-macos-14 display_os: darwin rust_target: aarch64-apple-darwin arch: aarch64 - goarch: arm64 steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -85,26 +81,11 @@ jobs: run: | cargo build --release --manifest-path linkup-cli/Cargo.toml --target ${{ matrix.rust_target }} - - name: Setup Go - uses: actions/setup-go@v4 - with: - go-version: "1.23" - - - name: Install xcaddy - run: go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest - - - name: Build Caddy Binary - run: | - export GOOS=${{ matrix.display_os }} - export GOARCH=${{ matrix.goarch }} - xcaddy build --output linkup-caddy --with github.com/mentimeter/caddy-dns-linkup --with github.com/mentimeter/caddy-storage-linkup - - name: Move binaries to upload run: | mkdir release-package cp target/${{ matrix.rust_target }}/release/linkup release-package/ - cp linkup-caddy release-package/ - name: Package Release Tarball id: package-release diff --git a/Cargo.lock b/Cargo.lock index 2cd6d80b..efb242c4 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" @@ -1535,8 +1618,11 @@ dependencies = [ "mockall", "mockito", "rand", + "rcgen", "regex", "reqwest", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_yaml", @@ -1553,6 +1639,7 @@ name = "linkup-local-server" version = "0.1.0" dependencies = [ "axum 0.8.1", + "axum-server", "futures", "http", "hyper", @@ -1563,7 +1650,7 @@ dependencies = [ "rustls-native-certs", "thiserror 2.0.11", "tokio", - "tower", + "tower 0.5.2", "tower-http", ] @@ -1809,12 +1896,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 +1939,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 +2027,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 +2267,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 +2366,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tower", + "tower 0.5.2", "tower-service", "url", "wasm-bindgen", @@ -2319,6 +2458,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 +3123,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 +3839,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 +3868,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/Cargo.toml b/linkup-cli/Cargo.toml index abd95073..cc34c4eb 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -21,6 +21,9 @@ linkup = { path = "../linkup" } linkup-local-server = { path = "../local-server" } log = "0.4.25" rand = "0.8.5" +rcgen = { version = "0.13", features = ["x509-parser"] } +rustls = { version = "0.23.21", default-features = false, features = ["ring"] } +rustls-pemfile = "2.2.0" regex = "1.11.1" reqwest = { version = "0.12.12", default-features = false, features = [ "json", diff --git a/linkup-cli/src/certificates.rs b/linkup-cli/src/certificates.rs new file mode 100644 index 00000000..09191cec --- /dev/null +++ b/linkup-cli/src/certificates.rs @@ -0,0 +1,102 @@ +use std::{fs, io::BufReader, path::PathBuf, process}; + +use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; +use rustls_pemfile::certs; + +use crate::{linkup_certs_dir_path, linkup_dir_path}; + +pub fn ca_cert_pem_path() -> PathBuf { + linkup_certs_dir_path().join("mentimeter_ca.cert.pem") +} + +pub fn ca_key_pem_path() -> PathBuf { + linkup_certs_dir_path().join("mentimeter_ca.key.pem") +} + +pub fn get_cert_pair(domain: &str) -> Option<(Certificate, KeyPair)> { + let escaped_domain = domain.replace("*", "wildcard_"); + let cert_path = linkup_certs_dir_path().join(format!("{}.cert.pem", &escaped_domain)); + let key_path = linkup_certs_dir_path().join(format!("{}.key.pem", &escaped_domain)); + + if !cert_path.exists() || !key_path.exists() { + return None; + } + + let cert_pem_str = fs::read_to_string(cert_path).unwrap(); + let key_pem_str = fs::read_to_string(key_path).unwrap(); + + let params = CertificateParams::from_ca_cert_pem(&cert_pem_str).unwrap(); + let key_pair = KeyPair::from_pem(&key_pem_str).unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + Some((cert, key_pair)) +} + +pub fn create_domain_cert(domain: &str) -> (Certificate, KeyPair) { + let cert_pem_str = fs::read_to_string(ca_cert_pem_path()).unwrap(); + let key_pem_str = fs::read_to_string(ca_key_pem_path()).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 = linkup_certs_dir_path().join(format!("{}.cert.pem", &escaped_domain)); + let key_path = linkup_certs_dir_path().join(format!("{}.key.pem", &escaped_domain)); + fs::write(cert_path, cert.pem()).unwrap(); + fs::write(key_path, key_pair.serialize_pem()).unwrap(); + + println!("Certificate for {} generated!", domain); + + (cert, key_pair) +} + +/// Return if a new certificate/keypair was generated +pub fn upsert_ca_cert() -> (Certificate, KeyPair) { + if let Some(cert_pair) = get_cert_pair("mentimeter_ca") { + return cert_pair; + } + + 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, "Mentimeter Local CA"); + + let key_pair = KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + fs::write(ca_cert_pem_path(), cert.pem()).unwrap(); + fs::write(ca_key_pem_path(), key_pair.serialize_pem()).unwrap(); + + (cert, key_pair) +} + +pub async fn add_ca_to_keychain() { + 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()) + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .spawn() + .expect("Failed to add CA to keychain"); +} 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..94e97eac 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -6,9 +6,9 @@ use std::{ use clap::Subcommand; use crate::{ - commands, is_sudo, + certificates, commands, is_sudo, linkup_certs_dir_path, local_config::{config_path, get_config}, - services, sudo_su, CliError, Result, + sudo_su, CliError, Result, }; #[derive(clap::Args)] @@ -48,11 +48,12 @@ 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()))?; + ensure_certs_dir()?; + certificates::upsert_ca_cert(); + certificates::add_ca_to_keychain().await; + for domain in input_config.top_level_domains() { + certificates::create_domain_cert(&format!("*.{}", domain)); + } Ok(()) } @@ -71,10 +72,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(()) } @@ -94,6 +91,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/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index e634ee57..f9a82d04 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,6 +1,9 @@ use std::fs; -use crate::CliError; +use linkup::MemoryStringStore; +use tokio::select; + +use crate::{linkup_certs_dir_path, CliError}; #[derive(clap::Args)] pub struct Args { @@ -12,11 +15,30 @@ pub async fn server(args: &Args) -> 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(); + }); + + let https_config_store = config_store.clone(); + let handler_https = tokio::spawn(async move { + // TODO: Build SNI from the domains present on the config + let cert_path = linkup_certs_dir_path().join("wildcard_.mentimeter.dev.cert.pem"); + let key_path = linkup_certs_dir_path().join("wildcard_.mentimeter.dev.key.pem"); + + linkup_local_server::start_server_https(https_config_store, &cert_path, &key_path) + .await + .unwrap(); + }); - if let Err(pid_file_err) = fs::remove_file(&args.pidfile) { - eprintln!("Failed to remove pidfile: {}", pid_file_err); + select! { + _ = handler_http => (), + _ = handler_https => (), } - res.map_err(|e| e.into()) + 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 4162f12c..f7696482 100644 --- a/linkup-cli/src/commands/update.rs +++ b/linkup-cli/src/commands/update.rs @@ -1,4 +1,4 @@ -use crate::{current_version, linkup_exe_path, release, services, CliError, InstallationMethod}; +use crate::{current_version, linkup_exe_path, release, CliError, InstallationMethod}; use std::fs; #[derive(clap::Args)] @@ -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 = downloaded_update.caddy_path().unwrap(); - - let current_caddy_path = services::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 => { diff --git a/linkup-cli/src/main.rs b/linkup-cli/src/main.rs index 67ef57fc..bb40ef67 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -6,6 +6,7 @@ use thiserror::Error; pub use linkup::Version; +mod certificates; mod commands; mod env_files; mod local_config; @@ -15,7 +16,7 @@ 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_LOCALSERVER_PORT: u16 = 80; const LINKUP_DIR: &str = ".linkup"; const LINKUP_STATE_FILE: &str = "state"; @@ -62,6 +63,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); @@ -143,8 +150,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 2cd5f011..cf314927 100644 --- a/linkup-cli/src/release.rs +++ b/linkup-cli/src/release.rs @@ -43,15 +43,6 @@ impl DownloadedAsset { None } - - pub fn caddy_path(&self) -> Option { - let caddy_path = self.path.join("linkup-caddy"); - if caddy_path.exists() { - return Some(caddy_path); - } - - None - } } impl Asset { diff --git a/linkup-cli/src/services/caddy.rs b/linkup-cli/src/services/caddy.rs deleted file mode 100644 index 5be56a11..00000000 --- a/linkup-cli/src/services/caddy.rs +++ /dev/null @@ -1,332 +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 caddy_path = get_path(); - - if fs::exists(&caddy_path)? { - log::debug!( - "Caddy executable already exists on {}", - &caddy_path.display() - ); - return Ok(()); - } - - let version = current_version(); - match release::fetch_release(&version.to_string()).await? { - Some(release) => { - let os = env::consts::OS; - let arch = env::consts::ARCH; - - match release.matching_asset(os, arch) { - Some(asset) => match asset.download_decompressed().await { - Ok(downloaded_asset) => match downloaded_asset.caddy_path() { - Some(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)?; - } - None => { - log::warn!( - "Failed to find Caddy binary on release for version {}", - &version - ); - - return Err(InstallError::AssetNotFound(version.clone())); - } - }, - Err(error) => { - log::warn!("Failed to download asset: {}", error); - - return Err(InstallError::AssetDownload( - "Failed to download asset".to_string(), - )); - } - }, - None => { - log::debug!( - "Linkup release for OS '{}' and ARCH '{}' not found on version {}", - os, - arch, - &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 path = get_path(); - - 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)?; - let path = get_path(); - - #[cfg(target_os = "macos")] - let status = Command::new(&path) - .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(&path) - .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 get_path() -> PathBuf { - let mut path = linkup_bin_dir_path(); - path.push("linkup-caddy"); - - path -} - -pub fn is_installed() -> bool { - get_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/local_server.rs b/linkup-cli/src/services/local_server.rs index 75f21514..c9373ec0 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -7,19 +7,21 @@ use std::{ time::Duration, }; +use hickory_resolver::proto::rr::rdata::https; +use linkup::MemoryStringStore; use reqwest::StatusCode; use tokio::time::sleep; use url::Url; use crate::{ - linkup_file_path, + certificates, linkup_file_path, local_config::{upload_state, LocalState}, worker_client, }; use super::{get_running_pid, stop_pid_file, BackgroundService, Pid, PidError, Signal}; -pub const LINKUP_LOCAL_SERVER_PORT: u16 = 9066; +pub const LINKUP_LOCAL_SERVER_PORT: u16 = 80; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -181,14 +183,9 @@ impl BackgroundService for LocalServer { } } - match self.update_state(state).await { - Ok(_) => { - self.notify_update(&status_sender, super::RunStatus::Started); - } - Err(e) => { - self.notify_update(&status_sender, super::RunStatus::Error); - return Err(e); - } + if let Err(e) = self.update_state(state).await { + self.notify_update(&status_sender, super::RunStatus::Error); + return Err(e); } Ok(()) diff --git a/linkup-cli/src/services/mod.rs b/linkup-cli/src/services/mod.rs index 9e679dd8..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::get_path as caddy_path, caddy::is_installed as is_caddy_installed, caddy::Caddy}; pub use { cloudflare_tunnel::is_installed as is_cloudflared_installed, cloudflare_tunnel::CloudflareTunnel, diff --git a/local-server/Cargo.toml b/local-server/Cargo.toml index 41b8e375..4d895d09 100644 --- a/local-server/Cargo.toml +++ b/local-server/Cargo.toml @@ -9,8 +9,9 @@ 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 = { version = "1.5.2", features = ["server"] } hyper-rustls = "0.27.5" hyper-util = { version = "0.1.10", features = ["client-legacy"] } futures = "0.3.31" diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index f2d97ba4..0e1910a6 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -1,3 +1,5 @@ +use std::{net::SocketAddr, path::PathBuf}; + use axum::{ body::Body, extract::{DefaultBodyLimit, Json, Request}, @@ -6,6 +8,7 @@ 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::{ @@ -23,7 +26,7 @@ use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; type HttpsClient = Client, Body>; -const LINKUP_LOCALSERVER_PORT: u16 = 9066; +const LINKUP_LOCALSERVER_PORT: u16 = 80; #[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,12 +73,35 @@ 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, + cert_path: &PathBuf, + key_path: &PathBuf, +) -> std::io::Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let config = RustlsConfig::from_pem_file(cert_path, key_path) + .await + .unwrap(); + + let app = linkup_router(config_store); + + let addr = SocketAddr::from(([127, 0, 0, 1], 443)); + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service()) + .await + .unwrap(); + + Ok(()) +} + +pub async fn start_server_http(config_store: MemoryStringStore) -> std::io::Result<()> { + let app = linkup_router(config_store); - let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", LINKUP_LOCALSERVER_PORT)) + let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:80")) .await .unwrap(); + println!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) @@ -87,7 +112,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( 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 d6f1e13f19ab402c85a191edaf68029a6f237c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 6 Mar 2025 10:50:37 +0100 Subject: [PATCH 2/7] feat: hide local-dns behind feature flag and only allow macos --- linkup-cli/Cargo.toml | 4 +++ linkup-cli/build.rs | 10 ++++++ linkup-cli/src/commands/health.rs | 44 ++++++++++++++++++------- linkup-cli/src/commands/local_dns.rs | 14 -------- linkup-cli/src/commands/mod.rs | 2 ++ linkup-cli/src/commands/start.rs | 3 ++ linkup-cli/src/commands/stop.rs | 2 ++ linkup-cli/src/main.rs | 3 ++ linkup-cli/src/services/local_server.rs | 4 +-- linkup-cli/src/services/mod.rs | 2 ++ 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index cc34c4eb..aab0ace4 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -50,3 +50,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/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 94e97eac..e827bb01 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -127,8 +127,6 @@ fn install_resolvers(resolve_domains: &[String]) -> Result<()> { } flush_dns_cache()?; - - #[cfg(target_os = "macos")] kill_dns_responder()?; Ok(()) @@ -151,8 +149,6 @@ fn uninstall_resolvers(resolve_domains: &[String]) -> Result<()> { } flush_dns_cache()?; - - #[cfg(target_os = "macos")] kill_dns_responder()?; Ok(()) @@ -176,15 +172,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() @@ -199,7 +186,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"]) 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..7f9a4619 100644 --- a/linkup-cli/src/commands/stop.rs +++ b/linkup-cli/src/commands/stop.rs @@ -31,6 +31,8 @@ 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 bb40ef67..7c991769 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -6,6 +6,7 @@ use thiserror::Error; pub use linkup::Version; +#[cfg(feature = "localdns")] mod certificates; mod commands; mod env_files; @@ -238,6 +239,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), @@ -293,6 +295,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/local_server.rs b/linkup-cli/src/services/local_server.rs index c9373ec0..c7d78a20 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -7,14 +7,12 @@ use std::{ time::Duration, }; -use hickory_resolver::proto::rr::rdata::https; -use linkup::MemoryStringStore; use reqwest::StatusCode; use tokio::time::sleep; use url::Url; use crate::{ - certificates, linkup_file_path, + linkup_file_path, local_config::{upload_state, LocalState}, worker_client, }; 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 601d9ddaaa65190c1aa3901adf2b6b67c63067ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 6 Mar 2025 10:59:49 +0100 Subject: [PATCH 3/7] feat: add NSS support --- linkup-cli/src/certificates.rs | 87 +++++++++++++++++++++++++--- linkup-cli/src/commands/local_dns.rs | 4 +- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/linkup-cli/src/certificates.rs b/linkup-cli/src/certificates.rs index 09191cec..abd1e7cd 100644 --- a/linkup-cli/src/certificates.rs +++ b/linkup-cli/src/certificates.rs @@ -1,16 +1,17 @@ -use std::{fs, io::BufReader, path::PathBuf, process}; +use std::{env, fs, path::PathBuf, process}; use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; -use rustls_pemfile::certs; -use crate::{linkup_certs_dir_path, linkup_dir_path}; +use crate::linkup_certs_dir_path; + +const LINKUP_CA_COMMON_NAME: &str = "Linkup Local CA"; pub fn ca_cert_pem_path() -> PathBuf { - linkup_certs_dir_path().join("mentimeter_ca.cert.pem") + linkup_certs_dir_path().join("linkup_ca.cert.pem") } pub fn ca_key_pem_path() -> PathBuf { - linkup_certs_dir_path().join("mentimeter_ca.key.pem") + linkup_certs_dir_path().join("linkup_ca.key.pem") } pub fn get_cert_pair(domain: &str) -> Option<(Certificate, KeyPair)> { @@ -61,7 +62,7 @@ pub fn create_domain_cert(domain: &str) -> (Certificate, KeyPair) { /// Return if a new certificate/keypair was generated pub fn upsert_ca_cert() -> (Certificate, KeyPair) { - if let Some(cert_pair) = get_cert_pair("mentimeter_ca") { + if let Some(cert_pair) = get_cert_pair("linkup_ca") { return cert_pair; } @@ -74,7 +75,7 @@ pub fn upsert_ca_cert() -> (Certificate, KeyPair) { params .distinguished_name - .push(rcgen::DnType::CommonName, "Mentimeter Local CA"); + .push(rcgen::DnType::CommonName, LINKUP_CA_COMMON_NAME); let key_pair = KeyPair::generate().unwrap(); let cert = params.self_signed(&key_pair).unwrap(); @@ -85,7 +86,7 @@ pub fn upsert_ca_cert() -> (Certificate, KeyPair) { (cert, key_pair) } -pub async fn add_ca_to_keychain() { +pub fn add_ca_to_keychain() { process::Command::new("sudo") .arg("security") .arg("add-trusted-cert") @@ -100,3 +101,73 @@ pub async fn add_ca_to_keychain() { .spawn() .expect("Failed to add CA to keychain"); } + +pub fn install_nss() { + if is_nss_installed() { + println!("NSS already installed, skipping installation"); + return; + } + + let mut cmd = process::Command::new("brew") + .arg("install") + .arg("nss") + .spawn() + .expect("Failed to install NSS"); + + cmd.wait().expect("Failed to wait for NSS install"); +} + +pub fn add_ca_to_nss() { + if !is_nss_installed() { + println!("NSS not found, skipping CA installation"); + return; + } + + let home = env::var("HOME").expect("Failed to get HOME env var"); + let firefox_profiles = + fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) + .expect("Failed to read Firefox profiles directory") + .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() { + return Some(format!("{}{}", "sql:", path.to_str().unwrap())); + } else if path.join("cert8.db").exists() { + return Some(format!("{}{}", "dmb:", path.to_str().unwrap())); + } else { + None + } + } else { + None + } + }) + .collect::>(); + + for profile in firefox_profiles { + 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()) + .spawn() + .expect("Failed to add CA to NSS"); + } +} + +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() + .unwrap(); + + res.success() +} diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index e827bb01..eafdca02 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -50,7 +50,9 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_certs_dir()?; certificates::upsert_ca_cert(); - certificates::add_ca_to_keychain().await; + certificates::add_ca_to_keychain(); + certificates::install_nss(); + certificates::add_ca_to_nss(); for domain in input_config.top_level_domains() { certificates::create_domain_cert(&format!("*.{}", domain)); } From f25c9a1d475e9343487b7d8a7e2bdb5f55b6d2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Thu, 6 Mar 2025 15:44:48 +0100 Subject: [PATCH 4/7] fix: proxy requests correctly --- local-server/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 0e1910a6..168d6aac 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -26,8 +26,6 @@ use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; type HttpsClient = Client, Body>; -const LINKUP_LOCALSERVER_PORT: u16 = 80; - #[derive(Debug)] struct ApiError { message: String, @@ -123,7 +121,7 @@ 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(_) => { @@ -365,6 +363,7 @@ fn https_client() -> HttpsClient { .with_tls_config(tls) .https_or_http() .enable_http1() + .enable_http2() .build(); Client::builder(TokioExecutor::new()).build(https) From d735efa532fa05cea312c3ebdbb2b2afd8d819f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Fri, 7 Mar 2025 14:39:21 +0100 Subject: [PATCH 5/7] feat: implement SNI on local-server --- Cargo.lock | 5 +- linkup-cli/Cargo.toml | 3 - linkup-cli/src/certificates.rs | 173 --------------- linkup-cli/src/commands/local_dns.rs | 21 +- linkup-cli/src/commands/server.rs | 15 +- linkup-cli/src/commands/start.rs | 6 + linkup-cli/src/main.rs | 4 +- linkup-cli/src/release.rs | 1 + linkup-cli/src/services/local_server.rs | 3 +- linkup/src/lib.rs | 3 +- local-server/Cargo.toml | 4 +- local-server/src/certificates.rs | 271 ++++++++++++++++++++++++ local-server/src/lib.rs | 27 +-- 13 files changed, 323 insertions(+), 213 deletions(-) delete mode 100644 linkup-cli/src/certificates.rs create mode 100644 local-server/src/certificates.rs diff --git a/Cargo.lock b/Cargo.lock index efb242c4..8010ea62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1618,11 +1618,8 @@ dependencies = [ "mockall", "mockito", "rand", - "rcgen", "regex", "reqwest", - "rustls", - "rustls-pemfile", "serde", "serde_json", "serde_yaml", @@ -1646,8 +1643,10 @@ dependencies = [ "hyper-rustls", "hyper-util", "linkup", + "rcgen", "rustls", "rustls-native-certs", + "rustls-pemfile", "thiserror 2.0.11", "tokio", "tower 0.5.2", diff --git a/linkup-cli/Cargo.toml b/linkup-cli/Cargo.toml index aab0ace4..c24d4532 100644 --- a/linkup-cli/Cargo.toml +++ b/linkup-cli/Cargo.toml @@ -21,9 +21,6 @@ linkup = { path = "../linkup" } linkup-local-server = { path = "../local-server" } log = "0.4.25" rand = "0.8.5" -rcgen = { version = "0.13", features = ["x509-parser"] } -rustls = { version = "0.23.21", default-features = false, features = ["ring"] } -rustls-pemfile = "2.2.0" regex = "1.11.1" reqwest = { version = "0.12.12", default-features = false, features = [ "json", diff --git a/linkup-cli/src/certificates.rs b/linkup-cli/src/certificates.rs deleted file mode 100644 index abd1e7cd..00000000 --- a/linkup-cli/src/certificates.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::{env, fs, path::PathBuf, process}; - -use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; - -use crate::linkup_certs_dir_path; - -const LINKUP_CA_COMMON_NAME: &str = "Linkup Local CA"; - -pub fn ca_cert_pem_path() -> PathBuf { - linkup_certs_dir_path().join("linkup_ca.cert.pem") -} - -pub fn ca_key_pem_path() -> PathBuf { - linkup_certs_dir_path().join("linkup_ca.key.pem") -} - -pub fn get_cert_pair(domain: &str) -> Option<(Certificate, KeyPair)> { - let escaped_domain = domain.replace("*", "wildcard_"); - let cert_path = linkup_certs_dir_path().join(format!("{}.cert.pem", &escaped_domain)); - let key_path = linkup_certs_dir_path().join(format!("{}.key.pem", &escaped_domain)); - - if !cert_path.exists() || !key_path.exists() { - return None; - } - - let cert_pem_str = fs::read_to_string(cert_path).unwrap(); - let key_pem_str = fs::read_to_string(key_path).unwrap(); - - let params = CertificateParams::from_ca_cert_pem(&cert_pem_str).unwrap(); - let key_pair = KeyPair::from_pem(&key_pem_str).unwrap(); - let cert = params.self_signed(&key_pair).unwrap(); - - Some((cert, key_pair)) -} - -pub fn create_domain_cert(domain: &str) -> (Certificate, KeyPair) { - let cert_pem_str = fs::read_to_string(ca_cert_pem_path()).unwrap(); - let key_pem_str = fs::read_to_string(ca_key_pem_path()).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 = linkup_certs_dir_path().join(format!("{}.cert.pem", &escaped_domain)); - let key_path = linkup_certs_dir_path().join(format!("{}.key.pem", &escaped_domain)); - fs::write(cert_path, cert.pem()).unwrap(); - fs::write(key_path, key_pair.serialize_pem()).unwrap(); - - println!("Certificate for {} generated!", domain); - - (cert, key_pair) -} - -/// Return if a new certificate/keypair was generated -pub fn upsert_ca_cert() -> (Certificate, KeyPair) { - if let Some(cert_pair) = get_cert_pair("linkup_ca") { - return cert_pair; - } - - 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(), cert.pem()).unwrap(); - fs::write(ca_key_pem_path(), key_pair.serialize_pem()).unwrap(); - - (cert, key_pair) -} - -pub fn add_ca_to_keychain() { - 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()) - .stdout(process::Stdio::piped()) - .stderr(process::Stdio::piped()) - .spawn() - .expect("Failed to add CA to keychain"); -} - -pub fn install_nss() { - if is_nss_installed() { - println!("NSS already installed, skipping installation"); - return; - } - - let mut cmd = process::Command::new("brew") - .arg("install") - .arg("nss") - .spawn() - .expect("Failed to install NSS"); - - cmd.wait().expect("Failed to wait for NSS install"); -} - -pub fn add_ca_to_nss() { - if !is_nss_installed() { - println!("NSS not found, skipping CA installation"); - return; - } - - let home = env::var("HOME").expect("Failed to get HOME env var"); - let firefox_profiles = - fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) - .expect("Failed to read Firefox profiles directory") - .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() { - return Some(format!("{}{}", "sql:", path.to_str().unwrap())); - } else if path.join("cert8.db").exists() { - return Some(format!("{}{}", "dmb:", path.to_str().unwrap())); - } else { - None - } - } else { - None - } - }) - .collect::>(); - - for profile in firefox_profiles { - 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()) - .spawn() - .expect("Failed to add CA to NSS"); - } -} - -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() - .unwrap(); - - res.success() -} diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index eafdca02..3914fd71 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -6,7 +6,7 @@ use std::{ use clap::Subcommand; use crate::{ - certificates, commands, is_sudo, linkup_certs_dir_path, + commands, is_sudo, linkup_certs_dir_path, local_config::{config_path, get_config}, sudo_su, CliError, Result, }; @@ -49,12 +49,19 @@ pub async fn install(config_arg: &Option) -> Result<()> { install_resolvers(&input_config.top_level_domains())?; ensure_certs_dir()?; - certificates::upsert_ca_cert(); - certificates::add_ca_to_keychain(); - certificates::install_nss(); - certificates::add_ca_to_nss(); - for domain in input_config.top_level_domains() { - certificates::create_domain_cert(&format!("*.{}", domain)); + let certs_dir = linkup_certs_dir_path(); + linkup_local_server::certificates::upsert_ca_cert(&certs_dir); + linkup_local_server::certificates::add_ca_to_keychain(&certs_dir); + linkup_local_server::certificates::install_nss(); + linkup_local_server::certificates::add_ca_to_nss(&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)); } Ok(()) diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index f9a82d04..b50eddc8 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,9 +1,9 @@ -use std::fs; - use linkup::MemoryStringStore; +use std::fs; +use std::path::PathBuf; use tokio::select; -use crate::{linkup_certs_dir_path, CliError}; +use crate::CliError; #[derive(clap::Args)] pub struct Args { @@ -11,7 +11,7 @@ pub struct Args { pidfile: String, } -pub async fn server(args: &Args) -> Result<(), CliError> { +pub async fn server(args: &Args, certs_dir: &PathBuf) -> Result<(), CliError> { let pid = std::process::id(); fs::write(&args.pidfile, pid.to_string())?; @@ -25,12 +25,9 @@ pub async fn server(args: &Args) -> Result<(), CliError> { }); let https_config_store = config_store.clone(); + let https_certs_dir = certs_dir.clone(); let handler_https = tokio::spawn(async move { - // TODO: Build SNI from the domains present on the config - let cert_path = linkup_certs_dir_path().join("wildcard_.mentimeter.dev.cert.pem"); - let key_path = linkup_certs_dir_path().join("wildcard_.mentimeter.dev.key.pem"); - - linkup_local_server::start_server_https(https_config_store, &cert_path, &key_path) + linkup_local_server::start_server_https(https_config_store, &https_certs_dir) .await .unwrap(); }); diff --git a/linkup-cli/src/commands/start.rs b/linkup-cli/src/commands/start.rs index 088de0fb..73521675 100644 --- a/linkup-cli/src/commands/start.rs +++ b/linkup-cli/src/commands/start.rs @@ -14,8 +14,10 @@ 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::{local_config::LocalState, CliError}; @@ -76,6 +78,10 @@ pub async fn start( // send the message to the display thread to stop and we join it. let mut exit_error: Option> = None; + 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/main.rs b/linkup-cli/src/main.rs index 7c991769..d5c25783 100644 --- a/linkup-cli/src/main.rs +++ b/linkup-cli/src/main.rs @@ -6,8 +6,6 @@ use thiserror::Error; pub use linkup::Version; -#[cfg(feature = "localdns")] -mod certificates; mod commands; mod env_files; mod local_config; @@ -299,7 +297,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/release.rs b/linkup-cli/src/release.rs index cf314927..0a9d5aff 100644 --- a/linkup-cli/src/release.rs +++ b/linkup-cli/src/release.rs @@ -187,6 +187,7 @@ async fn fetch_latest_release() -> Result { client.execute(req).await?.json().await } +#[allow(dead_code)] pub async fn fetch_release(tag: &str) -> Result, reqwest::Error> { let url: Url = format!( "https://api.github.com/repos/mentimeter/linkup/releases/tags/{}", diff --git a/linkup-cli/src/services/local_server.rs b/linkup-cli/src/services/local_server.rs index c7d78a20..8ea34937 100644 --- a/linkup-cli/src/services/local_server.rs +++ b/linkup-cli/src/services/local_server.rs @@ -61,8 +61,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", diff --git a/linkup/src/lib.rs b/linkup/src/lib.rs index 8a4caeb9..85a82724 100644 --- a/linkup/src/lib.rs +++ b/linkup/src/lib.rs @@ -166,7 +166,8 @@ pub fn get_target_service( config: &Session, session_name: &str, ) -> Option { - let mut target = Url::parse(url).unwrap(); + let mut target = Url::parse(url).expect(format!("Target URL '{}' to be valid", url).as_str()); + // Ensure always the default port, even when the local server is hit first target .set_port(None) diff --git a/local-server/Cargo.toml b/local-server/Cargo.toml index 4d895d09..d9435e58 100644 --- a/local-server/Cargo.toml +++ b/local-server/Cargo.toml @@ -18,7 +18,9 @@ futures = "0.3.31" linkup = { path = "../linkup" } rustls = { version = "0.23.21", default-features = false, features = ["ring"] } rustls-native-certs = "0.8.1" +rcgen = { version = "0.13", features = ["x509-parser"] } 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" +rustls-pemfile = "2.2.0" diff --git a/local-server/src/certificates.rs b/local-server/src/certificates.rs new file mode 100644 index 00000000..c0fc1497 --- /dev/null +++ b/local-server/src/certificates.rs @@ -0,0 +1,271 @@ +use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; +use rustls::crypto::ring::sign; +use rustls::pki_types::CertificateDer; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::{env, fs, path::PathBuf, process}; + +const LINKUP_CA_COMMON_NAME: &str = "Linkup Local CA"; + +pub fn ca_cert_pem_path(certs_dir: &PathBuf) -> PathBuf { + certs_dir.join("linkup_ca.cert.pem") +} + +pub fn ca_key_pem_path(certs_dir: &PathBuf) -> PathBuf { + certs_dir.join("linkup_ca.key.pem") +} + +#[derive(Debug)] +pub struct WildcardSniResolver { + certs: RwLock>>, +} + +impl WildcardSniResolver { + fn new() -> Self { + Self { + certs: RwLock::new(HashMap::new()), + } + } + + fn add_cert(&self, domain: &str, cert: CertifiedKey) { + let mut certs = self.certs.write().unwrap(); + certs.insert(domain.to_string(), Arc::new(cert)); + } + + fn find_cert(&self, server_name: &str) -> Option> { + 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 + } +} + +impl ResolvesServerCert for WildcardSniResolver { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + if let Some(name) = client_hello.server_name() { + return self.find_cert(name.as_ref()); + } + + None + } +} + +pub fn load_certificates_from_dir(cert_dir: &PathBuf) -> WildcardSniResolver { + let resolver = WildcardSniResolver::new(); + + let entries = fs::read_dir(cert_dir).expect("Failed to read certs directory"); + + for entry in entries.flatten() { + let path = entry.path(); + + if path + .file_name() + .unwrap() + .to_string_lossy() + .contains(".cert.pem") + && !path.starts_with("linkup_ca") + { + let path_str = path.to_string_lossy(); + let domain_name = path + .file_name() + .unwrap() + .to_string_lossy() + .replace(".cert.pem", "") + .replace("wildcard_", "*"); + let key_path = PathBuf::from(path_str.replace(".cert.pem", ".key.pem")); + + if key_path.exists() { + match load_cert_and_key(path, key_path) { + Ok(certified_key) => { + println!("Loaded certificate for {}", domain_name); + resolver.add_cert(&domain_name, certified_key); + } + Err(e) => { + eprintln!("Error loading cert/key for {domain_name}: {e}"); + } + } + } + } + } + + resolver +} + +fn load_cert_and_key( + cert_path: PathBuf, + key_path: PathBuf, +) -> Result> { + let cert_pem = fs::read(cert_path)?; + let key_pem = fs::read(key_path)?; + + let certs = rustls_pemfile::certs(&mut &cert_pem[..]) + .filter_map(|cert| cert.ok()) + .map(CertificateDer::from) + .collect::>>(); + + if certs.is_empty() { + return Err("No valid certificates found".into()); + } + + let key_der = + rustls_pemfile::private_key(&mut &key_pem[..])?.ok_or("No valid private key found")?; + + let signing_key = + sign::any_supported_type(&key_der).map_err(|_| "Failed to parse signing key")?; + + Ok(CertifiedKey { + cert: certs, + key: signing_key, + ocsp: None, + }) +} + +pub fn create_domain_cert(certs_dir: &PathBuf, 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(); + + println!("Certificate for {} generated!", domain); + + (cert, key_pair) +} + +pub fn upsert_ca_cert(certs_dir: &PathBuf) { + 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(); +} + +pub fn add_ca_to_keychain(certs_dir: &PathBuf) { + 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()) + .spawn() + .expect("Failed to add CA to keychain"); +} + +pub fn install_nss() { + if is_nss_installed() { + println!("NSS already installed, skipping installation"); + return; + } + + let mut cmd = process::Command::new("brew") + .arg("install") + .arg("nss") + .spawn() + .expect("Failed to install NSS"); + + cmd.wait().expect("Failed to wait for NSS install"); +} + +pub fn add_ca_to_nss(certs_dir: &PathBuf) { + if !is_nss_installed() { + println!("NSS not found, skipping CA installation"); + return; + } + + let home = env::var("HOME").expect("Failed to get HOME env var"); + let firefox_profiles = + fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) + .expect("Failed to read Firefox profiles directory") + .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() { + return Some(format!("{}{}", "sql:", path.to_str().unwrap())); + } else if path.join("cert8.db").exists() { + return Some(format!("{}{}", "dmb:", path.to_str().unwrap())); + } else { + None + } + } else { + None + } + }) + .collect::>(); + + for profile in firefox_profiles { + 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)) + .spawn() + .expect("Failed to add CA to NSS"); + } +} + +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() + .unwrap(); + + res.success() +} diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 168d6aac..da4cdcad 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -1,5 +1,3 @@ -use std::{net::SocketAddr, path::PathBuf}; - use axum::{ body::Body, extract::{DefaultBodyLimit, Json, Request}, @@ -15,15 +13,19 @@ 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::sync::Arc; +use std::{net::SocketAddr, path::PathBuf}; use tokio::signal; use tower::ServiceBuilder; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; +pub mod certificates; + type HttpsClient = Client, Body>; #[derive(Debug)] @@ -73,19 +75,21 @@ pub fn linkup_router(config_store: MemoryStringStore) -> Router { pub async fn start_server_https( config_store: MemoryStringStore, - cert_path: &PathBuf, - key_path: &PathBuf, + certs_dir: &PathBuf, ) -> std::io::Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); - let config = RustlsConfig::from_pem_file(cert_path, key_path) - .await - .unwrap(); + let sni = certificates::load_certificates_from_dir(certs_dir); + 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 addr = SocketAddr::from(([127, 0, 0, 1], 443)); - axum_server::bind_rustls(addr, config) + println!("listening on {}", &addr); + axum_server::bind_rustls(addr, RustlsConfig::from_config(Arc::new(server_config))) .serve(app.into_make_service()) .await .unwrap(); @@ -96,9 +100,7 @@ pub async fn start_server_https( pub async fn start_server_http(config_store: MemoryStringStore) -> std::io::Result<()> { let app = linkup_router(config_store); - let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:80")) - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:80").await.unwrap(); println!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app) @@ -122,6 +124,7 @@ async fn linkup_request_handler( let headers: linkup::HeaderMap = req.headers().into(); let url = req.uri().to_string(); + let (session_name, config) = match sessions.get_request_session(&url, &headers).await { Ok(session) => session, Err(_) => { From 4f9fe78589fa8c2785605b1bc2abbd20c2230d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Mon, 10 Mar 2025 12:52:31 +0000 Subject: [PATCH 6/7] refactor: restructure code --- linkup-cli/src/commands/local_dns.rs | 4 +- linkup-cli/src/commands/server.rs | 6 +- linkup/src/lib.rs | 3 +- .../{certificates.rs => certificates/mod.rs} | 139 ++++-------------- .../src/certificates/wildcard_sni_resolver.rs | 89 +++++++++++ local-server/src/lib.rs | 8 +- 6 files changed, 128 insertions(+), 121 deletions(-) rename local-server/src/{certificates.rs => certificates/mod.rs} (58%) create mode 100644 local-server/src/certificates/wildcard_sni_resolver.rs diff --git a/linkup-cli/src/commands/local_dns.rs b/linkup-cli/src/commands/local_dns.rs index 3914fd71..746fa45a 100644 --- a/linkup-cli/src/commands/local_dns.rs +++ b/linkup-cli/src/commands/local_dns.rs @@ -50,8 +50,7 @@ pub async fn install(config_arg: &Option) -> Result<()> { ensure_certs_dir()?; let certs_dir = linkup_certs_dir_path(); - linkup_local_server::certificates::upsert_ca_cert(&certs_dir); - linkup_local_server::certificates::add_ca_to_keychain(&certs_dir); + linkup_local_server::certificates::install_ca_certificate(&certs_dir); linkup_local_server::certificates::install_nss(); linkup_local_server::certificates::add_ca_to_nss(&certs_dir); @@ -62,6 +61,7 @@ pub async fn install(config_arg: &Option) -> Result<()> { .collect::>() { linkup_local_server::certificates::create_domain_cert(&certs_dir, &format!("*.{}", domain)); + println!("Created certificate for {}", domain); } Ok(()) diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index b50eddc8..5b7f4cc8 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -1,6 +1,6 @@ use linkup::MemoryStringStore; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tokio::select; use crate::CliError; @@ -11,7 +11,7 @@ pub struct Args { pidfile: String, } -pub async fn server(args: &Args, certs_dir: &PathBuf) -> 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())?; @@ -25,7 +25,7 @@ pub async fn server(args: &Args, certs_dir: &PathBuf) -> Result<(), CliError> { }); let https_config_store = config_store.clone(); - let https_certs_dir = certs_dir.clone(); + let https_certs_dir = PathBuf::from(certs_dir); let handler_https = tokio::spawn(async move { linkup_local_server::start_server_https(https_config_store, &https_certs_dir) .await diff --git a/linkup/src/lib.rs b/linkup/src/lib.rs index 85a82724..615412e5 100644 --- a/linkup/src/lib.rs +++ b/linkup/src/lib.rs @@ -159,6 +159,7 @@ pub struct TargetService { } // TODO(ostenbom): Accept a http::Uri instead of a string. Change TargetService to use Uri instead of String. +// TODO(augustoccesar)[2025-03-10]: Handle error and return a result instead of panicking. // Returns a (name, url) pair for the destination service, if the request could be served by the config pub fn get_target_service( url: &str, @@ -166,7 +167,7 @@ pub fn get_target_service( config: &Session, session_name: &str, ) -> Option { - let mut target = Url::parse(url).expect(format!("Target URL '{}' to be valid", url).as_str()); + let mut target = Url::parse(url).unwrap_or_else(|_| panic!("Target URL '{}' to be valid", url)); // Ensure always the default port, even when the local server is hit first target diff --git a/local-server/src/certificates.rs b/local-server/src/certificates/mod.rs similarity index 58% rename from local-server/src/certificates.rs rename to local-server/src/certificates/mod.rs index c0fc1497..d9e9c534 100644 --- a/local-server/src/certificates.rs +++ b/local-server/src/certificates/mod.rs @@ -1,120 +1,36 @@ +mod wildcard_sni_resolver; + use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair}; use rustls::crypto::ring::sign; use rustls::pki_types::CertificateDer; -use rustls::server::{ClientHello, ResolvesServerCert}; use rustls::sign::CertifiedKey; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; -use std::{env, fs, path::PathBuf, process}; +use std::{ + env, fs, + 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: &PathBuf) -> PathBuf { +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: &PathBuf) -> PathBuf { +pub fn ca_key_pem_path(certs_dir: &Path) -> PathBuf { certs_dir.join("linkup_ca.key.pem") } -#[derive(Debug)] -pub struct WildcardSniResolver { - certs: RwLock>>, -} - -impl WildcardSniResolver { - fn new() -> Self { - Self { - certs: RwLock::new(HashMap::new()), - } - } - - fn add_cert(&self, domain: &str, cert: CertifiedKey) { - let mut certs = self.certs.write().unwrap(); - certs.insert(domain.to_string(), Arc::new(cert)); - } - - fn find_cert(&self, server_name: &str) -> Option> { - 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 - } -} - -impl ResolvesServerCert for WildcardSniResolver { - fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { - if let Some(name) = client_hello.server_name() { - return self.find_cert(name.as_ref()); - } - - None - } -} - -pub fn load_certificates_from_dir(cert_dir: &PathBuf) -> WildcardSniResolver { - let resolver = WildcardSniResolver::new(); - - let entries = fs::read_dir(cert_dir).expect("Failed to read certs directory"); - - for entry in entries.flatten() { - let path = entry.path(); - - if path - .file_name() - .unwrap() - .to_string_lossy() - .contains(".cert.pem") - && !path.starts_with("linkup_ca") - { - let path_str = path.to_string_lossy(); - let domain_name = path - .file_name() - .unwrap() - .to_string_lossy() - .replace(".cert.pem", "") - .replace("wildcard_", "*"); - let key_path = PathBuf::from(path_str.replace(".cert.pem", ".key.pem")); - - if key_path.exists() { - match load_cert_and_key(path, key_path) { - Ok(certified_key) => { - println!("Loaded certificate for {}", domain_name); - resolver.add_cert(&domain_name, certified_key); - } - Err(e) => { - eprintln!("Error loading cert/key for {domain_name}: {e}"); - } - } - } - } - } - - resolver -} - fn load_cert_and_key( - cert_path: PathBuf, - key_path: PathBuf, + cert_path: &Path, + key_path: &Path, ) -> Result> { let cert_pem = fs::read(cert_path)?; let key_pem = fs::read(key_path)?; let certs = rustls_pemfile::certs(&mut &cert_pem[..]) .filter_map(|cert| cert.ok()) - .map(CertificateDer::from) .collect::>>(); if certs.is_empty() { @@ -134,7 +50,7 @@ fn load_cert_and_key( }) } -pub fn create_domain_cert(certs_dir: &PathBuf, domain: &str) -> (Certificate, KeyPair) { +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(); @@ -156,12 +72,15 @@ pub fn create_domain_cert(certs_dir: &PathBuf, domain: &str) -> (Certificate, Ke fs::write(cert_path, cert.pem()).unwrap(); fs::write(key_path, key_pair.serialize_pem()).unwrap(); - println!("Certificate for {} generated!", domain); - (cert, key_pair) } -pub fn upsert_ca_cert(certs_dir: &PathBuf) { +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; } @@ -184,7 +103,7 @@ pub fn upsert_ca_cert(certs_dir: &PathBuf) { fs::write(ca_key_pem_path(certs_dir), key_pair.serialize_pem()).unwrap(); } -pub fn add_ca_to_keychain(certs_dir: &PathBuf) { +fn add_ca_to_keychain(certs_dir: &Path) { process::Command::new("sudo") .arg("security") .arg("add-trusted-cert") @@ -196,7 +115,7 @@ pub fn add_ca_to_keychain(certs_dir: &PathBuf) { .arg(ca_cert_pem_path(certs_dir)) .stdout(process::Stdio::piped()) .stderr(process::Stdio::piped()) - .spawn() + .status() .expect("Failed to add CA to keychain"); } @@ -206,16 +125,14 @@ pub fn install_nss() { return; } - let mut cmd = process::Command::new("brew") + process::Command::new("brew") .arg("install") .arg("nss") - .spawn() + .status() .expect("Failed to install NSS"); - - cmd.wait().expect("Failed to wait for NSS install"); } -pub fn add_ca_to_nss(certs_dir: &PathBuf) { +pub fn add_ca_to_nss(certs_dir: &Path) { if !is_nss_installed() { println!("NSS not found, skipping CA installation"); return; @@ -230,9 +147,9 @@ pub fn add_ca_to_nss(certs_dir: &PathBuf) { let path = entry.path(); if path.is_dir() { if path.join("cert9.db").exists() { - return Some(format!("{}{}", "sql:", path.to_str().unwrap())); + Some(format!("{}{}", "sql:", path.to_str().unwrap())) } else if path.join("cert8.db").exists() { - return Some(format!("{}{}", "dmb:", path.to_str().unwrap())); + Some(format!("{}{}", "dmb:", path.to_str().unwrap())) } else { None } @@ -253,7 +170,7 @@ pub fn add_ca_to_nss(certs_dir: &PathBuf) { .arg(LINKUP_CA_COMMON_NAME) .arg("-i") .arg(ca_cert_pem_path(certs_dir)) - .spawn() + .status() .expect("Failed to add CA to NSS"); } } 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..162b5fcf --- /dev/null +++ b/local-server/src/certificates/wildcard_sni_resolver.rs @@ -0,0 +1,89 @@ +use crate::certificates::load_cert_and_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)] +pub struct WildcardSniResolver { + certs: RwLock>>, +} + +impl WildcardSniResolver { + fn new() -> Self { + Self { + certs: RwLock::new(HashMap::new()), + } + } + + pub fn load_dir(certs_dir: &Path) -> Self { + let resolver = WildcardSniResolver::new(); + + let entries = fs::read_dir(certs_dir).expect("Failed to read certs directory"); + + for entry in entries.flatten() { + let path = entry.path(); + + if path + .file_name() + .unwrap() + .to_string_lossy() + .contains(".cert.pem") + && !path.starts_with("linkup_ca") + { + let path_str = path.to_string_lossy(); + let domain_name = path + .file_name() + .unwrap() + .to_string_lossy() + .replace(".cert.pem", "") + .replace("wildcard_", "*"); + let key_path = PathBuf::from(path_str.replace(".cert.pem", ".key.pem")); + + if key_path.exists() { + match load_cert_and_key(&path, &key_path) { + Ok(certified_key) => { + println!("Loaded certificate for {}", domain_name); + resolver.add_cert(&domain_name, certified_key); + } + Err(e) => { + eprintln!("Error loading cert/key for {domain_name}: {e}"); + } + } + } + } + } + + 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 da4cdcad..613578e0 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -18,8 +18,8 @@ use linkup::{ Session, SessionAllocator, TargetService, UpdateSessionRequest, }; use rustls::ServerConfig; -use std::sync::Arc; -use std::{net::SocketAddr, path::PathBuf}; +use std::net::SocketAddr; +use std::{path::Path, sync::Arc}; use tokio::signal; use tower::ServiceBuilder; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; @@ -75,11 +75,11 @@ pub fn linkup_router(config_store: MemoryStringStore) -> Router { pub async fn start_server_https( config_store: MemoryStringStore, - certs_dir: &PathBuf, + certs_dir: &Path, ) -> std::io::Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); - let sni = certificates::load_certificates_from_dir(certs_dir); + let sni = certificates::WildcardSniResolver::load_dir(certs_dir); let mut server_config = ServerConfig::builder() .with_no_client_auth() .with_cert_resolver(Arc::new(sni)); From eb90e52cfafd166ba2586d4e467b1ee821003fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augusto=20C=C3=A9sar?= Date: Mon, 10 Mar 2025 16:19:56 +0000 Subject: [PATCH 7/7] refactor: change some error handling --- linkup-cli/src/commands/server.rs | 4 +- local-server/src/certificates/mod.rs | 112 +++++++++++------- .../src/certificates/wildcard_sni_resolver.rs | 59 ++++----- local-server/src/lib.rs | 21 ++-- 4 files changed, 114 insertions(+), 82 deletions(-) diff --git a/linkup-cli/src/commands/server.rs b/linkup-cli/src/commands/server.rs index 5b7f4cc8..db7052e7 100644 --- a/linkup-cli/src/commands/server.rs +++ b/linkup-cli/src/commands/server.rs @@ -27,9 +27,7 @@ pub async fn server(args: &Args, certs_dir: &Path) -> Result<(), CliError> { let https_config_store = config_store.clone(); let https_certs_dir = PathBuf::from(certs_dir); let handler_https = tokio::spawn(async move { - linkup_local_server::start_server_https(https_config_store, &https_certs_dir) - .await - .unwrap(); + linkup_local_server::start_server_https(https_config_store, &https_certs_dir).await; }); select! { diff --git a/local-server/src/certificates/mod.rs b/local-server/src/certificates/mod.rs index d9e9c534..73117dc6 100644 --- a/local-server/src/certificates/mod.rs +++ b/local-server/src/certificates/mod.rs @@ -5,7 +5,9 @@ use rustls::crypto::ring::sign; use rustls::pki_types::CertificateDer; use rustls::sign::CertifiedKey; use std::{ - env, fs, + env, + fs::{self, File}, + io::BufReader, path::{Path, PathBuf}, process, }; @@ -22,26 +24,37 @@ pub fn ca_key_pem_path(certs_dir: &Path) -> PathBuf { certs_dir.join("linkup_ca.key.pem") } -fn load_cert_and_key( +#[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 cert_pem = fs::read(cert_path)?; - let key_pem = fs::read(key_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[..]) + let certs = rustls_pemfile::certs(&mut cert_pem) .filter_map(|cert| cert.ok()) .collect::>>(); if certs.is_empty() { - return Err("No valid certificates found".into()); + return Err(BuildCertifiedKeyError::InvalidCertFile); } - let key_der = - rustls_pemfile::private_key(&mut &key_pem[..])?.ok_or("No valid private key found")?; + 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(|_| "Failed to parse signing key")?; + sign::any_supported_type(&key_der).map_err(|_| BuildCertifiedKeyError::InvalidKeyFile)?; Ok(CertifiedKey { cert: certs, @@ -139,39 +152,47 @@ pub fn add_ca_to_nss(certs_dir: &Path) { } let home = env::var("HOME").expect("Failed to get HOME env var"); - let firefox_profiles = - fs::read_dir(PathBuf::from(home).join("Library/Application Support/Firefox/Profiles")) - .expect("Failed to read Firefox profiles directory") - .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())) + 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 } - } 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); } - }) - .collect::>(); - - for profile in firefox_profiles { - 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() - .expect("Failed to add CA to NSS"); + } + } + Err(error) => { + eprintln!("Failed to load Firefox profiles: {}", error); + } } } @@ -181,8 +202,13 @@ fn is_nss_installed() -> bool { .stdout(process::Stdio::null()) .stderr(process::Stdio::null()) .stdin(process::Stdio::null()) - .status() - .unwrap(); - - res.success() + .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 index 162b5fcf..b2947733 100644 --- a/local-server/src/certificates/wildcard_sni_resolver.rs +++ b/local-server/src/certificates/wildcard_sni_resolver.rs @@ -1,4 +1,4 @@ -use crate::certificates::load_cert_and_key; +use crate::certificates::build_certified_key; use rustls::server::{ClientHello, ResolvesServerCert}; use rustls::sign::CertifiedKey; use std::collections::HashMap; @@ -6,6 +6,18 @@ 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>>, @@ -18,45 +30,36 @@ impl WildcardSniResolver { } } - pub fn load_dir(certs_dir: &Path) -> Self { + pub fn load_dir(certs_dir: &Path) -> Result { let resolver = WildcardSniResolver::new(); - let entries = fs::read_dir(certs_dir).expect("Failed to read certs directory"); + 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 path - .file_name() - .unwrap() - .to_string_lossy() - .contains(".cert.pem") - && !path.starts_with("linkup_ca") - { - let path_str = path.to_string_lossy(); - let domain_name = path - .file_name() - .unwrap() - .to_string_lossy() - .replace(".cert.pem", "") - .replace("wildcard_", "*"); - let key_path = PathBuf::from(path_str.replace(".cert.pem", ".key.pem")); - - if key_path.exists() { - match load_cert_and_key(&path, &key_path) { - Ok(certified_key) => { - println!("Loaded certificate for {}", domain_name); - resolver.add_cert(&domain_name, certified_key); - } - Err(e) => { - eprintln!("Error loading cert/key for {domain_name}: {e}"); + 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}"); + } } } } } } - resolver + Ok(resolver) } fn add_cert(&self, domain: &str, cert: CertifiedKey) { diff --git a/local-server/src/lib.rs b/local-server/src/lib.rs index 613578e0..afda740b 100644 --- a/local-server/src/lib.rs +++ b/local-server/src/lib.rs @@ -73,13 +73,20 @@ pub fn linkup_router(config_store: MemoryStringStore) -> Router { ) } -pub async fn start_server_https( - config_store: MemoryStringStore, - certs_dir: &Path, -) -> std::io::Result<()> { +pub async fn start_server_https(config_store: MemoryStringStore, certs_dir: &Path) { let _ = rustls::crypto::ring::default_provider().install_default(); - let sni = certificates::WildcardSniResolver::load_dir(certs_dir); + 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)); @@ -92,9 +99,7 @@ pub async fn start_server_https( axum_server::bind_rustls(addr, RustlsConfig::from_config(Arc::new(server_config))) .serve(app.into_make_service()) .await - .unwrap(); - - Ok(()) + .expect("failed to start HTTPS server"); } pub async fn start_server_http(config_store: MemoryStringStore) -> std::io::Result<()> {