From b85a9d9bc4d69b4b4f668061a6169ed643eb4335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Wed, 18 Feb 2026 10:12:34 +0100 Subject: [PATCH 1/4] Add ubuntu support --- .github/workflows/test.yml | 5 +- src/daemon/dns_server.rs | 37 ++-- src/infrastructure/certs/trust_store/linux.rs | 121 ++++++++++++ src/infrastructure/certs/trust_store/mod.rs | 13 +- src/infrastructure/dns/linux.rs | 183 ++++++++++++++++++ src/infrastructure/dns/mod.rs | 13 +- 6 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 src/infrastructure/certs/trust_store/linux.rs create mode 100644 src/infrastructure/dns/linux.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d40c80..951b77a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,10 @@ on: jobs: test: - runs-on: macos-latest + strategy: + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code diff --git a/src/daemon/dns_server.rs b/src/daemon/dns_server.rs index 1b0ce62..c17f259 100644 --- a/src/daemon/dns_server.rs +++ b/src/daemon/dns_server.rs @@ -47,27 +47,40 @@ impl DnsServer { // Bind UDP sockets let udp_v4 = UdpSocket::bind(ipv4_addr).await?; - let udp_v6 = UdpSocket::bind(ipv6_addr).await?; - - // Bind TCP listeners let tcp_v4 = TcpListener::bind(ipv4_addr).await?; - let tcp_v6 = TcpListener::bind(ipv6_addr).await?; - info!( - ipv4 = %ipv4_addr, - ipv6 = %ipv6_addr, - response_ip = %self.ip_resolver.lan_ip, - "DNS server listening" - ); + // IPv6 binds may fail on Linux where the IPv4 dual-stack socket already + // covers both protocols. This is fine — we just skip IPv6-specific listeners. + let udp_v6 = UdpSocket::bind(ipv6_addr).await.ok(); + let tcp_v6 = TcpListener::bind(ipv6_addr).await.ok(); + + if udp_v6.is_some() { + info!( + ipv4 = %ipv4_addr, + ipv6 = %ipv6_addr, + response_ip = %self.ip_resolver.lan_ip, + "DNS server listening" + ); + } else { + info!( + ipv4 = %ipv4_addr, + response_ip = %self.ip_resolver.lan_ip, + "DNS server listening (IPv6 unavailable, using IPv4 dual-stack)" + ); + } let ttl = self.ttl; let resolver = self.ip_resolver.clone(); tokio::select! { r = serve_udp(udp_v4, ttl, resolver.clone()) => r, - r = serve_udp(udp_v6, ttl, resolver.clone()) => r, r = serve_tcp(tcp_v4, ttl, resolver.clone()) => r, - r = serve_tcp(tcp_v6, ttl, resolver) => r, + r = async { + if let Some(s) = udp_v6 { serve_udp(s, ttl, resolver.clone()).await } else { std::future::pending().await } + } => r, + r = async { + if let Some(s) = tcp_v6 { serve_tcp(s, ttl, resolver.clone()).await } else { std::future::pending().await } + } => r, } } } diff --git a/src/infrastructure/certs/trust_store/linux.rs b/src/infrastructure/certs/trust_store/linux.rs new file mode 100644 index 0000000..3e5d8df --- /dev/null +++ b/src/infrastructure/certs/trust_store/linux.rs @@ -0,0 +1,121 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use super::super::CertError; +use super::TrustStore; + +const SYSTEM_CERT_PATH: &str = "/usr/local/share/ca-certificates/roxy-ca.crt"; + +/// Linux trust store implementation using `update-ca-certificates`. +/// +/// Standard on Debian/Ubuntu systems. Installs the Roxy Root CA into +/// the system certificate store so all domain certificates signed by +/// it are automatically trusted. +pub struct LinuxTrustStore; + +impl LinuxTrustStore { + pub fn new() -> Self { + Self + } +} + +impl TrustStore for LinuxTrustStore { + fn add_ca(&self, cert_path: &Path) -> Result<(), CertError> { + // Copy the CA certificate to the system certificates directory + fs::copy(cert_path, SYSTEM_CERT_PATH).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + CertError::PermissionDenied + } else { + CertError::TrustStoreError(format!( + "Failed to copy CA certificate to {}: {}", + SYSTEM_CERT_PATH, e + )) + } + })?; + + // Update the system trust store + let output = Command::new("update-ca-certificates") + .output() + .map_err(|e| { + CertError::TrustStoreError(format!( + "Failed to run update-ca-certificates: {}", + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("Permission denied") { + return Err(CertError::PermissionDenied); + } + + return Err(CertError::TrustStoreError(format!( + "update-ca-certificates failed: {}", + stderr + ))); + } + + Ok(()) + } + + fn remove_ca(&self) -> Result<(), CertError> { + let path = Path::new(SYSTEM_CERT_PATH); + if !path.exists() { + return Ok(()); + } + + fs::remove_file(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + CertError::PermissionDenied + } else { + CertError::TrustStoreError(format!( + "Failed to remove CA certificate from {}: {}", + SYSTEM_CERT_PATH, e + )) + } + })?; + + // Rebuild the trust store without the removed certificate + let output = Command::new("update-ca-certificates") + .arg("--fresh") + .output() + .map_err(|e| { + CertError::TrustStoreError(format!( + "Failed to run update-ca-certificates --fresh: {}", + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CertError::TrustStoreError(format!( + "update-ca-certificates --fresh failed: {}", + stderr + ))); + } + + Ok(()) + } + + fn is_ca_trusted(&self) -> Result { + Ok(Path::new(SYSTEM_CERT_PATH).exists()) + } +} + +impl Default for LinuxTrustStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trust_store_creation() { + let _store = LinuxTrustStore::new(); + } +} diff --git a/src/infrastructure/certs/trust_store/mod.rs b/src/infrastructure/certs/trust_store/mod.rs index 94db85b..4f50e65 100644 --- a/src/infrastructure/certs/trust_store/mod.rs +++ b/src/infrastructure/certs/trust_store/mod.rs @@ -8,6 +8,12 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::MacOsTrustStore; +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "linux")] +pub use linux::LinuxTrustStore; + /// Trait for platform-specific trust store operations (CA-based trust) pub trait TrustStore { /// Add the Root CA to the system trust store @@ -26,7 +32,12 @@ pub fn get_trust_store() -> Result, CertError> { Ok(Box::new(MacOsTrustStore::new())) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub fn get_trust_store() -> Result, CertError> { + Ok(Box::new(LinuxTrustStore::new())) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] pub fn get_trust_store() -> Result, CertError> { Err(CertError::TrustStoreError(format!( "Unsupported platform: {}", diff --git a/src/infrastructure/dns/linux.rs b/src/infrastructure/dns/linux.rs new file mode 100644 index 0000000..e913642 --- /dev/null +++ b/src/infrastructure/dns/linux.rs @@ -0,0 +1,183 @@ +use super::{DnsError, DnsService}; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::Duration; + +const RESOLVED_DROP_IN_DIR: &str = "/etc/systemd/resolved.conf.d"; +const RESOLVED_DROP_IN_FILE: &str = "/etc/systemd/resolved.conf.d/roxy.conf"; + +/// Linux DNS service using a systemd-resolved drop-in configuration. +/// +/// Configures systemd-resolved to route `.roxy` DNS queries to the Roxy DNS +/// server — the Linux equivalent of macOS's `/etc/resolver/roxy`. +/// +/// The `~roxy` routing domain ensures only `.roxy` lookups use this DNS server; +/// all other queries continue to use the normal per-link DNS servers. +pub struct LinuxDnsService; + +impl LinuxDnsService { + pub fn new() -> Self { + Self + } +} + +impl Default for LinuxDnsService { + fn default() -> Self { + Self::new() + } +} + +impl DnsService for LinuxDnsService { + fn setup(&self, port: u16) -> Result<(), DnsError> { + // Ensure the drop-in directory exists + let dir = Path::new(RESOLVED_DROP_IN_DIR); + if !dir.exists() { + fs::create_dir_all(dir).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + DnsError::PermissionDenied + } else { + DnsError::WriteError { + path: dir.to_path_buf(), + source: e, + } + } + })?; + } + + // Write the drop-in config. The `~roxy` routing domain tells + // systemd-resolved to send only `.roxy` queries to this DNS server. + let content = format!( + "# Managed by Roxy — do not edit.\n\ + [Resolve]\n\ + DNS=127.0.0.1:{port}\n\ + Domains=~roxy\n" + ); + + fs::write(RESOLVED_DROP_IN_FILE, content).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + DnsError::PermissionDenied + } else { + DnsError::WriteError { + path: RESOLVED_DROP_IN_FILE.into(), + source: e, + } + } + })?; + + // Restart systemd-resolved to pick up the new config + restart_resolved()?; + + Ok(()) + } + + fn cleanup(&self) -> Result<(), DnsError> { + let path = Path::new(RESOLVED_DROP_IN_FILE); + if path.exists() { + fs::remove_file(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + DnsError::PermissionDenied + } else { + DnsError::RemoveError { + path: path.to_path_buf(), + source: e, + } + } + })?; + + restart_resolved()?; + } + Ok(()) + } + + fn validate(&self) -> Result<(), DnsError> { + // Verify the drop-in file exists + if !Path::new(RESOLVED_DROP_IN_FILE).exists() { + return Err(DnsError::ValidationFailed( + "DNS drop-in file does not exist. Run 'sudo roxy install' to set up DNS.".into(), + )); + } + + // Check that systemd-resolved has picked up the config. + // Retry a few times as the service may need a moment after restart. + const MAX_RETRIES: u32 = 5; + const RETRY_DELAY_MS: u64 = 500; + + for attempt in 1..=MAX_RETRIES { + let output = Command::new("resolvectl") + .args(["status"]) + .output() + .map_err(|e| { + DnsError::ValidationFailed(format!("Failed to run resolvectl: {}", e)) + })?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for our DNS server and routing domain in the global section + let has_dns = stdout.contains("127.0.0.1"); + let has_domain = stdout.contains("~roxy"); + + if has_dns && has_domain { + return Ok(()); + } + } + + if attempt < MAX_RETRIES { + thread::sleep(Duration::from_millis(RETRY_DELAY_MS)); + } + } + + Err(DnsError::ValidationFailed( + "DNS routing for .roxy not found in resolvectl output after multiple attempts.\n\ + Try running 'sudo roxy install' again." + .into(), + )) + } + + fn is_configured(&self) -> bool { + Path::new(RESOLVED_DROP_IN_FILE).exists() + } +} + +/// Restart systemd-resolved to apply configuration changes. +fn restart_resolved() -> Result<(), DnsError> { + let output = Command::new("systemctl") + .args(["restart", "systemd-resolved"]) + .output() + .map_err(|e| { + DnsError::ValidationFailed(format!("Failed to restart systemd-resolved: {}", e)) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("Permission denied") || stderr.contains("Access denied") { + return Err(DnsError::PermissionDenied); + } + return Err(DnsError::ValidationFailed(format!( + "Failed to restart systemd-resolved: {}", + stderr.trim() + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_dropin_content_format() { + let port = 1053; + let content = format!( + "# Managed by Roxy — do not edit.\n\ + [Resolve]\n\ + DNS=127.0.0.1:{port}\n\ + Domains=~roxy\n" + ); + assert!(content.contains("[Resolve]")); + assert!(content.contains("DNS=127.0.0.1:1053")); + assert!(content.contains("Domains=~roxy")); + assert!(content.starts_with('#')); + } +} diff --git a/src/infrastructure/dns/mod.rs b/src/infrastructure/dns/mod.rs index 2f96d1a..01a4889 100644 --- a/src/infrastructure/dns/mod.rs +++ b/src/infrastructure/dns/mod.rs @@ -49,6 +49,12 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::MacOsDnsService; +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "linux")] +pub use linux::LinuxDnsService; + /// Get the DNS service for the current platform pub fn get_dns_service() -> Result, DnsError> { #[cfg(target_os = "macos")] @@ -56,7 +62,12 @@ pub fn get_dns_service() -> Result, DnsError> { Ok(Box::new(MacOsDnsService::new())) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + Ok(Box::new(LinuxDnsService::new())) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { Err(DnsError::UnsupportedPlatform( std::env::consts::OS.to_string(), From 1d7fe19e49d4ccde47d2c8c37c58277cbee28dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Thu, 19 Feb 2026 06:17:26 +0100 Subject: [PATCH 2/4] style: Formatting --- src/infrastructure/certs/trust_store/linux.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/infrastructure/certs/trust_store/linux.rs b/src/infrastructure/certs/trust_store/linux.rs index 3e5d8df..d96b1b0 100644 --- a/src/infrastructure/certs/trust_store/linux.rs +++ b/src/infrastructure/certs/trust_store/linux.rs @@ -38,10 +38,7 @@ impl TrustStore for LinuxTrustStore { let output = Command::new("update-ca-certificates") .output() .map_err(|e| { - CertError::TrustStoreError(format!( - "Failed to run update-ca-certificates: {}", - e - )) + CertError::TrustStoreError(format!("Failed to run update-ca-certificates: {}", e)) })?; if !output.status.success() { From f0ec1f8c30d3d34648f10b85cd9d6f3a4f6483da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Thu, 19 Feb 2026 06:49:58 +0100 Subject: [PATCH 3/4] docs: Update docs --- README.md | 40 +++++----- docs/README.md | 73 +++++++++++++---- docs/linux.md | 207 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 36 deletions(-) create mode 100644 docs/linux.md diff --git a/README.md b/README.md index dbc2120..fbb2cbd 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ HTTPS for every local project. - ✓ Share work across devices - ✓ No nginx. No dnsmasq. No Docker. No YAML. -> ⚠️ **Early Development**: Roxy is ready for daily use on macOS, but -> things may shift around. +> ⚠️ **Early Development**: Roxy is ready for daily use on macOS and Linux, +> but things may shift around. > [Report issues here](https://github.com/rbas/roxy/issues). --- @@ -37,6 +37,8 @@ HTTPS for every local project. ## Try It in 60 Seconds +**Homebrew:** + ```bash # 1. Install via Homebrew brew tap rbas/roxy @@ -52,10 +54,13 @@ roxy register myapp.roxy --route "/=3000" --route "/api=3001" sudo roxy start # 5. Open in browser -open https://myapp.roxy +open https://myapp.roxy # on macOS +xdg-open https://myapp.roxy # on linux ``` > **Note:** After first install, restart your browser for the certificates to be recognized. +> On Linux with snap browsers (Firefox, Chromium), see the +> [Linux guide](docs/linux.md#snap-browsers-and-certificate-trust) for an extra one-time step. **That's it.** Trusted HTTPS, no warnings, no config files. Just works. @@ -285,8 +290,8 @@ sudo brew services stop roxy 1. **`roxy install`** - Creates a trusted Root Certificate Authority (CA) - - Adds it to your system keychain (so browsers trust it) - - Configures DNS to resolve `.roxy` domains to `127.0.0.1` + - Adds it to your system trust store (macOS Keychain / Linux ca-certificates) + - Configures DNS to resolve `.roxy` domains (macOS `/etc/resolver/` / Linux systemd-resolved) - **⚠️ Restart your browser** after first install for certificates to be recognized 2. **`roxy register `** @@ -307,7 +312,7 @@ signed by your Root CA. WebSockets work transparently. DNS queries for - Config, certs, and CA: `/etc/roxy/` - Logs: `/var/log/roxy/` - PID file: `/var/run/roxy.pid` -- DNS configuration: `/etc/resolver/roxy` (macOS) +- DNS: `/etc/resolver/roxy` (macOS) or `/etc/systemd/resolved.conf.d/roxy.conf` (Linux) - Run `roxy uninstall` to remove everything cleanly For configuration details, logging options, and file locations see the [full documentation](docs/README.md). @@ -340,12 +345,12 @@ No `/etc/hosts`, no config files, no manual cert setup. ### Requirements -- **macOS** (Monterey or later recommended) - - Linux support is planned! [Track progress here](https://github.com/rbas/roxy/issues) +- **macOS** (Monterey or later) or **Linux** (Ubuntu 22.04+ / Debian 12+) - **Rust** toolchain (for building from source) - **sudo** access (needed for ports 80/443 and DNS configuration) +- **Linux only:** `systemd-resolved` (default on Ubuntu) -### Install via Homebrew (Recommended) +### Install via Homebrew ```bash brew tap rbas/roxy @@ -359,34 +364,24 @@ Download the latest binary for your platform from the [Releases page](https://gi **macOS (Apple Silicon):** ```bash -# Download the latest release curl -LO https://github.com/rbas/roxy/releases/latest/download/roxy-macos-arm64.tar.gz tar -xzf roxy-macos-arm64.tar.gz - -# Install sudo mv roxy /usr/local/bin/ - -# Verify installation roxy --version ``` ### Install from Source ```bash -# Clone the repository git clone https://github.com/rbas/roxy.git cd roxy - -# Build and install cargo install --path . - -# Verify installation roxy --version ``` ## What's Next -Roxy is ready for daily development use on macOS. Recent additions and future plans: +Roxy is ready for daily development use on macOS and Linux. Recent additions and future plans: - [x] **Pre-built binaries** — download and run without building from source (macOS ARM64) @@ -396,8 +391,8 @@ Roxy is ready for daily development use on macOS. Recent additions and future pl for static file routes - [x] **Wildcard subdomains** — `*.myapp.roxy` patterns with automatic certificate generation -- [ ] **Linux support** — extend to Linux development - environments +- [x] **Linux support** — Ubuntu/Debian with systemd-resolved + DNS integration and system CA trust - [ ] **Docker network DNS** — resolve `.roxy` domains inside containers without `extra_hosts` @@ -409,6 +404,7 @@ Have a feature idea? ## Documentation & Support - 📖 **Full documentation**: [docs/README.md](docs/README.md) +- 🐧 **Linux guide**: [docs/linux.md](docs/linux.md) - 🐛 **Having issues?**: Check the [troubleshooting guide](docs/README.md#troubleshooting) - 💬 **Questions or feedback?**: [Open an issue](https://github.com/rbas/roxy/issues) diff --git a/docs/README.md b/docs/README.md index 10cd1eb..5a456d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,26 +1,30 @@ # Roxy -Roxy is a local development proxy for macOS that gives -your projects custom `.roxy` domains with automatic HTTPS. -It ships as a single binary with no external dependencies. -Register a domain, point it at a port or directory, and -open `https://myapp.roxy` in your browser. +Roxy is a local development proxy for macOS and Linux that +gives your projects custom `.roxy` domains with automatic +HTTPS. It ships as a single binary with no external +dependencies. Register a domain, point it at a port or +directory, and open `https://myapp.roxy` in your browser. + +> **Linux users:** See [linux.md](linux.md) for +> platform-specific setup details and troubleshooting. ## Quick Start ```bash # Initial setup — installs Root CA, configures DNS, -# trusts the certificate in macOS Keychain +# trusts the certificate in the system trust store sudo roxy install # Register a domain that proxies to localhost:3000 -roxy register myapp.roxy --route "/=3000" +sudo roxy register myapp.roxy --route "/=3000" # Start the daemon (requires sudo for ports 80/443) sudo roxy start # Open in browser -open https://myapp.roxy +open https://myapp.roxy # macOS +xdg-open https://myapp.roxy # Linux ``` ## Commands Reference @@ -262,19 +266,29 @@ navigate subdirectories └── roxy.log # Daemon log file ``` -macOS DNS resolver file (created by `roxy install`): +DNS configuration (created by `roxy install`): + +**macOS:** ```text /etc/resolver/roxy ``` -This tells macOS to resolve all `*.roxy` domains through -the local DNS server. +**Linux (systemd-resolved):** + +```text +/etc/systemd/resolved.conf.d/roxy.conf +``` + +This tells the system to resolve all `*.roxy` domains +through the local DNS server. All paths are configurable via the `[paths]` section in `config.toml` (see [Configuration](#configuration)). -## Auto-Start with Homebrew +## Auto-Start on Boot + +### macOS (Homebrew) If you installed Roxy via Homebrew, use `brew services` to start it automatically at boot: @@ -290,6 +304,29 @@ sudo brew services stop roxy When managed by `brew services`, Roxy runs in foreground mode and launchd handles process supervision. +### Linux (systemd) + +Create a systemd service file: + +```bash +sudo tee /etc/systemd/system/roxy.service > /dev/null <<'EOF' +[Unit] +Description=Roxy local development proxy +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/roxy start --foreground +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now roxy +``` + ## Daemon: Foreground vs Background **Background** (default) — forks to the background, @@ -466,11 +503,16 @@ to access. ### Browser Shows "Not Secure" or Certificate Warnings **If you installed Roxy with your browser already open**, the browser won't -immediately pick up the newly trusted Root CA from the system keychain. +immediately pick up the newly trusted Root CA from the system trust store. **Solution:** Restart your browser completely after running `sudo roxy install`. Browsers cache the trusted certificate list at startup. +**Linux with snap browsers:** Snap-packaged browsers (Firefox, Chromium) +are sandboxed and cannot access the system trust store. See the +[Linux guide](linux.md#snap-browsers-and-certificate-trust) for a one-time +fix using `certutil`. + ### Certificates Show Wrong Domain Name If accessing `myapp.roxy` shows a certificate for a different domain, the daemon @@ -495,8 +537,11 @@ sudo roxy start Verify DNS is working: ```bash +# macOS dig myapp.roxy -# Should show: myapp.roxy. 0 IN A 127.0.0.1 + +# Linux +resolvectl query myapp.roxy ``` ### Port Already in Use diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000..008204b --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,207 @@ +# Roxy on Linux + +This guide covers Linux-specific setup details and +troubleshooting. For general usage, see the +[main documentation](README.md). + +## Supported Distributions + +Roxy is tested on **Ubuntu 22.04+** and **Debian 12+**. +It should work on any distribution that uses: + +- **systemd-resolved** for DNS +- **update-ca-certificates** for the system trust store + +Other distributions (Fedora, Arch, etc.) may work but +are not yet officially supported. + +## How It Works on Linux + +### DNS Resolution + +On macOS, Roxy uses `/etc/resolver/roxy` to route `.roxy` +DNS queries. On Linux, it uses **systemd-resolved** with +a drop-in configuration file: + +```text +/etc/systemd/resolved.conf.d/roxy.conf +``` + +This file tells systemd-resolved to forward all `.roxy` +queries to Roxy's built-in DNS server on port 1053. +Other DNS queries are unaffected. + +The file is created automatically by `sudo roxy install` +and removed by `sudo roxy uninstall`. + +### Certificate Trust + +On macOS, the Root CA is added to the system Keychain. +On Linux, Roxy copies the Root CA to the system +certificate store and runs `update-ca-certificates`: + +```text +/usr/local/share/ca-certificates/roxy-ca.crt +``` + +This makes the CA trusted by `curl`, `wget`, `git`, +Electron apps, and non-sandboxed browsers. + +## Snap Browsers and Certificate Trust + +**This is the most common issue on Linux.** + +Snap-packaged browsers (Firefox, Chromium) run in a +sandbox and **cannot access the system's certificate +trust store**. Even with proper system CA installation, +these browsers will show certificate warnings for +`.roxy` domains. + +This does **not** affect: + +- `curl`, `wget`, `git`, and other CLI tools +- Non-snap browsers (installed via apt/deb) +- Electron apps (VS Code, Slack, etc.) + +### Fix: Import the CA with certutil + +This is a **one-time** step per browser. Once the Root CA +is imported, all `.roxy` domains (including newly +registered ones) are automatically trusted. + +**1. Install certutil:** + +```bash +sudo apt install libnss3-tools +``` + +**2. Import the Roxy CA into your browser:** + +**Snap Firefox:** + +```bash +certutil -A -n "Roxy Local Development CA" -t "CT,C,C" \ + -i /etc/roxy/ca.crt \ + -d sql:$(find ~/snap/firefox/common/.mozilla/firefox \ + -name '*.default*' -type d | head -1)/ +``` + +**Snap Chromium:** + +```bash +certutil -A -n "Roxy Local Development CA" -t "CT,C,C" \ + -i /etc/roxy/ca.crt \ + -d sql:$(find ~/snap/chromium -name 'nssdb' \ + -type d | head -1)/ +``` + +No browser restart is needed after import. + +### Why Does This Happen? + +Snap applications are sandboxed using AppArmor and +seccomp. They cannot load the host system's p11-kit +trust modules, which is how most Linux applications +discover trusted CAs. The `certutil` command writes +directly into the browser's own NSS certificate +database, bypassing the sandbox limitation. + +### Removing the CA + +To remove the Roxy CA from a snap browser: + +**Firefox:** + +```bash +certutil -D -n "Roxy Local Development CA" \ + -d sql:$(find ~/snap/firefox/common/.mozilla/firefox \ + -name '*.default*' -type d | head -1)/ +``` + +**Chromium:** + +```bash +certutil -D -n "Roxy Local Development CA" \ + -d sql:$(find ~/snap/chromium -name 'nssdb' \ + -type d | head -1)/ +``` + +## Troubleshooting + +### DNS Not Resolving + +If `curl https://myapp.roxy` fails with "Could not +resolve host": + +**1. Check if systemd-resolved has the config:** + +```bash +resolvectl status +``` + +Look for `DNS Servers: 127.0.0.1:1053` and +`DNS Domain: ~roxy` in the global section. + +**2. Check if the Roxy DNS server responds:** + +```bash +dig @127.0.0.1 -p 1053 myapp.roxy +``` + +If this works but `resolvectl query` doesn't, the +drop-in config may need a restart: + +```bash +sudo systemctl restart systemd-resolved +``` + +**3. Verify the drop-in file exists:** + +```bash +cat /etc/systemd/resolved.conf.d/roxy.conf +``` + +If missing, re-run `sudo roxy install`. + +### Port Already in Use + +On Linux, check which process is using a port with `ss`: + +```bash +sudo ss -tlnp | grep ':80\b' +sudo ss -tlnp | grep ':443\b' +sudo ss -tlnp | grep ':1053\b' +``` + +Common culprits: Apache (`apache2`), nginx, or another +Roxy instance. Stop the conflicting service or change +Roxy's ports in `/etc/roxy/config.toml`. + +### Auto-Start with systemd + +Create a service file to start Roxy at boot: + +```bash +sudo tee /etc/systemd/system/roxy.service > /dev/null <<'EOF' +[Unit] +Description=Roxy local development proxy +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/roxy start --foreground +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now roxy +``` + +Check status: + +```bash +sudo systemctl status roxy +``` From cfcd881c515831922c418aaf6071cbd1762bcafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Thu, 19 Feb 2026 07:21:12 +0100 Subject: [PATCH 4/4] ci: Add linux binary into release --- .github/workflows/release.yml | 157 +++++++++++++++++++++++++++++----- scripts/formula.rb.template | 11 ++- 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ad7368..80b0501 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,10 @@ on: jobs: test: - runs-on: macos-latest + strategy: + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -26,7 +29,7 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: - shared-key: "roxy-test" + shared-key: "roxy-test-${{ matrix.os }}" cache-on-failure: true - name: Run unit tests @@ -35,17 +38,12 @@ jobs: - name: Run Clippy run: cargo clippy -- -D warnings - build: + build-macos: needs: test runs-on: macos-latest - permissions: - contents: write - steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -53,7 +51,7 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: - shared-key: "roxy-release" + shared-key: "roxy-release-macos" cache-on-failure: true - name: Extract version from tag @@ -88,15 +86,122 @@ jobs: cat roxy-${{ steps.version.outputs.version }}-macos-arm64.sha256 cat roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz.sha256 - - name: Install git-cliff - run: brew install git-cliff + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: | + target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64 + target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.sha256 + target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz + target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz.sha256 - - name: Generate changelog for this release - id: changelog + build-linux: + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: "roxy-release-linux" + cache-on-failure: true + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${{ github.ref_name }}" + fi + # Strip 'v' prefix if present + VERSION=$(echo "$TAG" | sed 's/^v//') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Build release binary + run: cargo build --release --bin roxy + + - name: Package binary run: | - # Generate changelog for this specific tag - git cliff --tag ${{ steps.version.outputs.tag }} --unreleased --strip all > release_notes.md - cat release_notes.md + cd target/release + # Copy raw binary with versioned name for direct downloads + cp roxy roxy-${{ steps.version.outputs.version }}-linux-amd64 + # Create tarball + tar -czf roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz roxy + + - name: Generate SHA256 checksums + run: | + cd target/release + sha256sum roxy-${{ steps.version.outputs.version }}-linux-amd64 > roxy-${{ steps.version.outputs.version }}-linux-amd64.sha256 + sha256sum roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz > roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz.sha256 + cat roxy-${{ steps.version.outputs.version }}-linux-amd64.sha256 + cat roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz.sha256 + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-artifacts + path: | + target/release/roxy-${{ steps.version.outputs.version }}-linux-amd64 + target/release/roxy-${{ steps.version.outputs.version }}-linux-amd64.sha256 + target/release/roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz + target/release/roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz.sha256 + + release: + needs: [build-macos, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${{ github.ref_name }}" + fi + # Strip 'v' prefix if present + VERSION=$(echo "$TAG" | sed 's/^v//') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + # Detect pre-release (version contains a hyphen, e.g. 0.5.0-beta.1) + if echo "$VERSION" | grep -q '-'; then + echo "prerelease=true" >> $GITHUB_OUTPUT + else + echo "prerelease=false" >> $GITHUB_OUTPUT + fi + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos-artifacts + path: artifacts/ + + - name: Download Linux artifacts + uses: actions/download-artifact@v4 + with: + name: linux-artifacts + path: artifacts/ + + - name: Generate changelog for this release + uses: orhun/git-cliff-action@v4 + with: + args: --latest --strip all + env: + OUTPUT: release_notes.md - name: Create GitHub release uses: softprops/action-gh-release@v1 @@ -104,27 +209,37 @@ jobs: tag_name: ${{ steps.version.outputs.tag }} name: Roxy ${{ steps.version.outputs.version }} body_path: release_notes.md + prerelease: ${{ steps.version.outputs.prerelease == 'true' }} files: | - target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64 - target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.sha256 - target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz - target/release/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz.sha256 + artifacts/roxy-${{ steps.version.outputs.version }}-macos-arm64 + artifacts/roxy-${{ steps.version.outputs.version }}-macos-arm64.sha256 + artifacts/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz + artifacts/roxy-${{ steps.version.outputs.version }}-macos-arm64.tar.gz.sha256 + artifacts/roxy-${{ steps.version.outputs.version }}-linux-amd64 + artifacts/roxy-${{ steps.version.outputs.version }}-linux-amd64.sha256 + artifacts/roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz + artifacts/roxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Update Homebrew formula + if: steps.version.outputs.prerelease == 'false' env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | VERSION="${{ steps.version.outputs.version }}" - SHA256=$(shasum -a 256 target/release/roxy-${VERSION}-macos-arm64.tar.gz | awk '{print $1}') + SHA256=$(cat artifacts/roxy-${VERSION}-macos-arm64.tar.gz.sha256 | awk '{print $1}') URL="https://github.com/rbas/roxy/releases/download/v${VERSION}/roxy-${VERSION}-macos-arm64.tar.gz" + LINUX_SHA256=$(cat artifacts/roxy-${VERSION}-linux-amd64.tar.gz.sha256 | awk '{print $1}') + LINUX_URL="https://github.com/rbas/roxy/releases/download/v${VERSION}/roxy-${VERSION}-linux-amd64.tar.gz" git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/rbas/homebrew-roxy.git /tmp/homebrew-roxy sed -e "s|__VERSION__|${VERSION}|g" \ -e "s|__URL__|${URL}|g" \ -e "s|__SHA256__|${SHA256}|g" \ + -e "s|__LINUX_URL__|${LINUX_URL}|g" \ + -e "s|__LINUX_SHA256__|${LINUX_SHA256}|g" \ scripts/formula.rb.template \ > /tmp/homebrew-roxy/Formula/roxy.rb diff --git a/scripts/formula.rb.template b/scripts/formula.rb.template index 69d9296..1c8b49e 100644 --- a/scripts/formula.rb.template +++ b/scripts/formula.rb.template @@ -13,12 +13,21 @@ class Roxy < Formula end end + on_linux do + if Hardware::CPU.intel? + url "__LINUX_URL__" + sha256 "__LINUX_SHA256__" + else + odie "Roxy on Linux currently only supports x86_64 (AMD64)." + end + end + def install bin.install "roxy" end service do - name macos: "cz.rbas.roxy" + name macos: "cz.rbas.roxy", linux: "roxy" run [opt_bin/"roxy", "--config", "/etc/roxy/config.toml", "start", "--foreground"] keep_alive true require_root true