From c9a05e8f7a86fd727467509d8c97563098ab94e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Mon, 9 Feb 2026 20:47:24 +0100 Subject: [PATCH 1/4] feat: Add integration with brew services --- .github/workflows/release.yml | 37 ++-------- Cargo.lock | 49 ------------- Cargo.toml | 1 - README.md | 26 ++++++- scripts/formula.rb.template | 36 ++++++++++ scripts/test-brew.sh | 100 ++++++++++++++++++++++++++ src/cli/install.rs | 27 +++++-- src/cli/list.rs | 9 ++- src/cli/logs.rs | 12 ++-- src/cli/register.rs | 14 +++- src/cli/reload.rs | 18 +++-- src/cli/restart.rs | 16 +++-- src/cli/route.rs | 14 ++-- src/cli/start.rs | 44 +++++++----- src/cli/status.rs | 11 +-- src/cli/stop.rs | 5 +- src/cli/uninstall.rs | 38 +++++++--- src/cli/unregister.rs | 9 ++- src/daemon/router.rs | 5 +- src/daemon/server.rs | 10 +-- src/daemon/tls.rs | 23 +++--- src/infrastructure/certs/ca.rs | 32 +++------ src/infrastructure/certs/generator.rs | 61 +++------------- src/infrastructure/certs/service.rs | 14 ++-- src/infrastructure/config/mod.rs | 84 ++++++++++++++++------ src/infrastructure/mod.rs | 1 + src/infrastructure/paths.rs | 11 +++ src/infrastructure/pid.rs | 16 ++--- src/infrastructure/tracing.rs | 9 --- src/main.rs | 55 ++++++++++---- 30 files changed, 480 insertions(+), 307 deletions(-) create mode 100644 scripts/formula.rb.template create mode 100755 scripts/test-brew.sh create mode 100644 src/infrastructure/paths.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1999fa6..7abfce2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,40 +119,17 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" SHA256=$(shasum -a 256 target/release/roxy-${VERSION}-macos-arm64.tar.gz | awk '{print $1}') + URL="https://github.com/rbas/roxy/releases/download/v${VERSION}/roxy-${VERSION}-macos-arm64.tar.gz" git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/rbas/homebrew-roxy.git /tmp/homebrew-roxy - cd /tmp/homebrew-roxy - - cat > Formula/roxy.rb << 'FORMULA' - class Roxy < Formula - desc "Local development proxy with custom .roxy domains and automatic HTTPS" - homepage "https://github.com/rbas/roxy" - license "MIT" - FORMULA - - cat >> Formula/roxy.rb << FORMULA - version "${VERSION}" - - on_macos do - if Hardware::CPU.arm? - url "https://github.com/rbas/roxy/releases/download/v${VERSION}/roxy-${VERSION}-macos-arm64.tar.gz" - sha256 "${SHA256}" - end - end - FORMULA - cat >> Formula/roxy.rb << 'FORMULA' - - def install - bin.install "roxy" - end - - test do - system "#{bin}/roxy", "--version" - end - end - FORMULA + sed -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__URL__|${URL}|g" \ + -e "s|__SHA256__|${SHA256}|g" \ + scripts/formula.rb.template \ + > /tmp/homebrew-roxy/Formula/roxy.rb + cd /tmp/homebrew-roxy git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/roxy.rb diff --git a/Cargo.lock b/Cargo.lock index 208d070..00f904b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,27 +338,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "dispatch2" version = "0.3.0" @@ -674,16 +653,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -851,12 +820,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "pem" version = "3.0.6" @@ -929,17 +892,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -979,7 +931,6 @@ dependencies = [ "axum", "clap", "ctrlc", - "dirs", "http-body-util", "humantime", "hyper", diff --git a/Cargo.toml b/Cargo.toml index df74465..f6d6c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ anyhow = "1.0" thiserror = "2.0" serde = { version = "1.0", features = ["derive"] } toml = "0.9" -dirs = "6.0" rcgen = { version = "0.14", features = ["pem"] } time = "0.3" diff --git a/README.md b/README.md index 652b736..c67a9e7 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,26 @@ roxy logs -f # Follow (like tail -f) roxy logs -n 100 # Last 100 lines ``` +## Auto-Start on Boot + +If you installed Roxy via Homebrew, you can use `brew services` to start Roxy +automatically at boot: + +```bash +# Start roxy now and auto-start at boot +sudo brew services start roxy +``` + +Roxy stores its configuration in `/etc/roxy/config.toml`, so it works +automatically when started by launchd at boot — no extra environment +variables needed. + +To stop auto-start: + +```bash +sudo brew services stop roxy +``` + ## How It Works **Three steps, then forget about it:** @@ -255,7 +275,9 @@ signed by your Root CA. WebSockets work transparently. DNS queries for **Clean and contained:** -- All config, certs, and logs: `~/.roxy/` +- Config, certs, and CA: `/etc/roxy/` +- Logs: `/var/log/roxy/` +- PID file: `/var/run/roxy.pid` - DNS configuration: `/etc/resolver/roxy` (macOS) - Run `roxy uninstall` to remove everything cleanly @@ -338,7 +360,7 @@ Roxy is ready for daily development use on macOS. Recent additions and future pl - [ ] **Docker network DNS** — resolve `.roxy` domains inside containers without `extra_hosts` - [ ] **Wildcard subdomains** — support `*.myapp.roxy` patterns -- [ ] **Auto-start on boot** — launch daemon via launchd automatically +- [x] **Auto-start on boot** — launch daemon via launchd with `brew services` Have a feature idea? [Open an issue](https://github.com/rbas/roxy/issues) and let's discuss! diff --git a/scripts/formula.rb.template b/scripts/formula.rb.template new file mode 100644 index 0000000..a8e3281 --- /dev/null +++ b/scripts/formula.rb.template @@ -0,0 +1,36 @@ +class Roxy < Formula + desc "Local development proxy with custom .roxy domains and automatic HTTPS" + homepage "https://github.com/rbas/roxy" + license "MIT" + version "__VERSION__" + + url "__URL__" + sha256 "__SHA256__" + + def install + bin.install "roxy" + end + + service do + name macos: "cz.rbas.roxy" + run [opt_bin/"roxy", "start", "--foreground"] + keep_alive true + require_root true + log_path var/"log/roxy/roxy.log" + error_log_path var/"log/roxy/roxy.log" + end + + def caveats + <<~EOS + Roxy requires a one-time setup before use: + sudo roxy install + + To start roxy now and auto-start at boot: + sudo brew services start roxy + EOS + end + + test do + system "#{bin}/roxy", "--version" + end +end diff --git a/scripts/test-brew.sh b/scripts/test-brew.sh new file mode 100755 index 0000000..efe023c --- /dev/null +++ b/scripts/test-brew.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Test Homebrew formula locally with brew services. +# +# Usage: +# ./scripts/test-brew.sh # build + install + show next steps +# ./scripts/test-brew.sh clean # uninstall and restore tap +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TAP_DIR="$(brew --prefix)/Library/Taps/rbas/homebrew-roxy" +TARBALL="/tmp/roxy-local-test.tar.gz" +VERSION=$(grep '^version' "$REPO_ROOT/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/') + +# ── Clean mode ───────────────────────────────────────────── +if [[ "${1:-}" == "clean" ]]; then + echo "==> Cleaning up test install..." + sudo brew services stop roxy 2>/dev/null || true + brew uninstall roxy 2>/dev/null || true + + # Restore tap from remote + if [[ -d "$TAP_DIR" ]]; then + echo "==> Restoring tap to remote version..." + cd "$TAP_DIR" + git checkout Formula/roxy.rb 2>/dev/null || true + fi + + # Clean up roxy state + sudo roxy uninstall --force 2>/dev/null || true + rm -f "$TARBALL" + + echo "Done. Tap restored, roxy uninstalled." + exit 0 +fi + +# ── Build ────────────────────────────────────────────────── +echo "==> Building release binary..." +cd "$REPO_ROOT" +cargo build --release + +echo "==> Creating tarball..." +tar -czf "$TARBALL" -C target/release roxy + +SHA256=$(shasum -a 256 "$TARBALL" | awk '{print $1}') +echo " tarball: $TARBALL" +echo " sha256: $SHA256" +echo " version: $VERSION" + +# ── Ensure tap exists ────────────────────────────────────── +if [[ ! -d "$TAP_DIR" ]]; then + echo "==> Tapping rbas/roxy..." + brew tap rbas/roxy +fi + +# ── Write local formula ─────────────────────────────────── +echo "==> Writing local formula to tap..." +mkdir -p "$TAP_DIR/Formula" + +sed -e "s|__VERSION__|$VERSION|g" \ + -e "s|__URL__|file://$TARBALL|g" \ + -e "s|__SHA256__|$SHA256|g" \ + "$REPO_ROOT/scripts/formula.rb.template" \ + > "$TAP_DIR/Formula/roxy.rb" + +# ── Install ──────────────────────────────────────────────── +echo "==> Installing roxy from local tap..." +brew reinstall roxy + +echo "" +echo "============================================" +echo " Roxy installed from local build!" +echo " Version: $(roxy --version)" +echo "============================================" +echo "" +echo "Now test the full flow:" +echo "" +echo " # 1. One-time setup" +echo " sudo roxy install" +echo "" +echo " # 2. Register a test domain" +echo " sudo roxy register test.roxy --route '/=8080'" +echo "" +echo " # 3a. Test manual start" +echo " sudo roxy start" +echo " roxy status" +echo " sudo roxy stop" +echo "" +echo " # 3b. Test brew services (auto-start at boot)" +echo " sudo brew services start roxy" +echo " sudo brew services info roxy" +echo " roxy status" +echo " sudo brew services stop roxy" +echo "" +echo " # 4. Verify config location" +echo " cat /etc/roxy/config.toml" +echo " ls -la /etc/roxy/" +echo "" +echo " # 5. Clean up when done" +echo " ./scripts/test-brew.sh clean" diff --git a/src/cli/install.rs b/src/cli/install.rs index e89ed80..01c71f9 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,15 +1,16 @@ +use std::path::Path; + use anyhow::Result; use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::config::{Config, ConfigStore}; use crate::infrastructure::dns::get_dns_service; use crate::infrastructure::network::get_lan_ip; +use crate::infrastructure::paths::RoxyPaths; -pub fn execute() -> Result<()> { +pub fn execute(config_path: &Path, paths: &RoxyPaths, config: &Config) -> Result<()> { println!("Setting up Roxy...\n"); - let config_store = ConfigStore::new(); - let config = config_store.load()?; let dns_port = config.daemon.dns_port; // Detect LAN IP @@ -19,8 +20,24 @@ pub fn execute() -> Result<()> { println!(" Warning: No network detected, using localhost."); } + // Ensure data directory exists + std::fs::create_dir_all(&paths.data_dir)?; + std::fs::create_dir_all(&paths.certs_dir)?; + + // Ensure log directory exists + if let Some(log_dir) = paths.log_file.parent() { + std::fs::create_dir_all(log_dir)?; + } + + // Write default config if it doesn't exist + if !config_path.exists() { + let config_store = ConfigStore::new(config_path.to_path_buf()); + config_store.save(config)?; + println!(" Created config file: {}", config_path.display()); + } + // Step 1: Initialize Root CA - let cert_service = CertificateService::new(); + let cert_service = CertificateService::new(paths); match cert_service.is_ca_installed() { Ok(true) => { println!(" Root CA already installed, skipping..."); diff --git a/src/cli/list.rs b/src/cli/list.rs index 52a5037..bb3f428 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -1,12 +1,15 @@ +use std::path::Path; + use anyhow::Result; use crate::domain::RouteTarget; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; -pub fn execute() -> Result<()> { - let config_store = ConfigStore::new(); - let cert_service = CertificateService::new(); +pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { + let config_store = ConfigStore::new(config_path.to_path_buf()); + let cert_service = CertificateService::new(paths); let domains = config_store.list_domains()?; if domains.is_empty() { diff --git a/src/cli/logs.rs b/src/cli/logs.rs index 58ada69..aa957dd 100644 --- a/src/cli/logs.rs +++ b/src/cli/logs.rs @@ -5,14 +5,14 @@ use std::time::Duration; use anyhow::{Context, Result}; -use crate::infrastructure::tracing::default_log_path; +use crate::infrastructure::paths::RoxyPaths; -pub fn execute(lines: usize, clear: bool, follow: bool) -> Result<()> { - let log_path = default_log_path(); +pub fn execute(lines: usize, clear: bool, follow: bool, paths: &RoxyPaths) -> Result<()> { + let log_path = &paths.log_file; if clear { if log_path.exists() { - fs::remove_file(&log_path).context("Failed to clear log file")?; + fs::remove_file(log_path).context("Failed to clear log file")?; } println!("Logs cleared."); return Ok(()); @@ -26,14 +26,14 @@ pub fn execute(lines: usize, clear: bool, follow: bool) -> Result<()> { } // Show last N lines - let content = tail_lines(&log_path, lines)?; + let content = tail_lines(log_path, lines)?; if !content.is_empty() { print!("{}", content); } // Follow mode: keep watching for new lines if follow { - tail_follow(&log_path)?; + tail_follow(log_path)?; } Ok(()) diff --git a/src/cli/register.rs b/src/cli/register.rs index 1a0e7a7..11d2909 100644 --- a/src/cli/register.rs +++ b/src/cli/register.rs @@ -1,10 +1,18 @@ +use std::path::Path; + use anyhow::{Result, bail}; use crate::domain::{DomainName, DomainRegistration, Route}; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; -pub fn execute(domain: String, routes: Vec) -> Result<()> { +pub fn execute( + domain: String, + routes: Vec, + config_path: &Path, + paths: &RoxyPaths, +) -> Result<()> { // Validate domain name let domain = DomainName::new(&domain)?; @@ -19,8 +27,8 @@ pub fn execute(domain: String, routes: Vec) -> Result<()> { .collect::, _>>() .map_err(|e| anyhow::anyhow!("Invalid route: {}", e))?; - let config_store = ConfigStore::new(); - let cert_service = CertificateService::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); + let cert_service = CertificateService::new(paths); // Check if already registered if config_store.get_domain(&domain)?.is_some() { diff --git a/src/cli/reload.rs b/src/cli/reload.rs index 47be098..9aa3251 100644 --- a/src/cli/reload.rs +++ b/src/cli/reload.rs @@ -1,9 +1,13 @@ +use std::path::Path; + use anyhow::{Result, bail}; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute() -> Result<()> { - let pid_file = PidFile::new(); +pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { + let pid_file = PidFile::new(paths.pid_file.clone()); if !pid_file.is_running()? { bail!("Roxy daemon is not running.\nStart it with: sudo roxy start"); @@ -12,13 +16,17 @@ pub fn execute() -> Result<()> { println!("Reloading Roxy daemon..."); // Stop the daemon - super::stop::execute()?; + super::stop::execute(paths)?; // Brief pause to ensure clean shutdown std::thread::sleep(std::time::Duration::from_millis(500)); - // Start the daemon - super::start::execute(false, false)?; + // Re-load config from disk to pick up changes + let config_store = ConfigStore::new(config_path.to_path_buf()); + let fresh_config = config_store.load()?; + + // Start the daemon with fresh config + super::start::execute(false, verbose, config_path, paths, &fresh_config)?; println!("Daemon reloaded with updated configuration."); Ok(()) diff --git a/src/cli/restart.rs b/src/cli/restart.rs index 754b799..d2d30aa 100644 --- a/src/cli/restart.rs +++ b/src/cli/restart.rs @@ -1,17 +1,25 @@ +use std::path::Path; + use anyhow::Result; use super::{start, stop}; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute() -> Result<()> { - let pid_file = PidFile::new(); +pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { + let pid_file = PidFile::new(paths.pid_file.clone()); if pid_file.is_running()? { println!("Stopping Roxy daemon..."); - stop::execute()?; + stop::execute(paths)?; std::thread::sleep(std::time::Duration::from_millis(500)); } + // Re-load config from disk to pick up changes + let config_store = ConfigStore::new(config_path.to_path_buf()); + let fresh_config = config_store.load()?; + println!("Starting Roxy daemon..."); - start::execute(false, false) + start::execute(false, verbose, config_path, paths, &fresh_config) } diff --git a/src/cli/route.rs b/src/cli/route.rs index 7cbabd1..a0e4293 100644 --- a/src/cli/route.rs +++ b/src/cli/route.rs @@ -1,16 +1,18 @@ +use std::path::Path; + use anyhow::Result; use crate::domain::{DomainName, PathPrefix, Route, RouteTarget}; use crate::infrastructure::config::ConfigStore; /// Add a route to an existing domain -pub fn add(domain: String, path: String, target: String) -> Result<()> { +pub fn add(domain: String, path: String, target: String, config_path: &Path) -> Result<()> { let domain = DomainName::new(&domain)?; let path_prefix = PathPrefix::new(&path)?; let route_target = RouteTarget::parse(&target) .map_err(|e| anyhow::anyhow!("Invalid target '{}': {}", target, e))?; - let config_store = ConfigStore::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); // Get existing registration let mut registration = config_store @@ -31,11 +33,11 @@ pub fn add(domain: String, path: String, target: String) -> Result<()> { } /// Remove a route from a domain -pub fn remove(domain: String, path: String) -> Result<()> { +pub fn remove(domain: String, path: String, config_path: &Path) -> Result<()> { let domain = DomainName::new(&domain)?; let path_prefix = PathPrefix::new(&path)?; - let config_store = ConfigStore::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); // Get existing registration let mut registration = config_store @@ -55,10 +57,10 @@ pub fn remove(domain: String, path: String) -> Result<()> { } /// List all routes for a domain -pub fn list(domain: String) -> Result<()> { +pub fn list(domain: String, config_path: &Path) -> Result<()> { let domain = DomainName::new(&domain)?; - let config_store = ConfigStore::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); // Get existing registration let registration = config_store diff --git a/src/cli/start.rs b/src/cli/start.rs index f9bce93..cd1f92e 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -1,13 +1,22 @@ +use std::path::Path; + use anyhow::{Context, Result, bail}; use std::env; use std::process::{Command, Stdio}; -use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::config::Config; use crate::infrastructure::network::get_lan_ip; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute(foreground: bool, verbose: bool) -> Result<()> { - let pid_file = PidFile::new(); +pub fn execute( + foreground: bool, + verbose: bool, + config_path: &Path, + paths: &RoxyPaths, + config: &Config, +) -> Result<()> { + let pid_file = PidFile::new(paths.pid_file.clone()); // Check if already running if let Some(pid) = pid_file.get_running_pid()? { @@ -18,24 +27,24 @@ pub fn execute(foreground: bool, verbose: bool) -> Result<()> { } // Validate configuration before starting - let config_store = ConfigStore::new(); - let config = config_store - .load() - .context("Failed to load configuration")?; - config .validate() .context("Configuration validation failed")?; if foreground { // Run in foreground (blocking) - run_server(verbose) + run_server(verbose, config_path, paths) } else { // Fork to background let exe = env::current_exe()?; let mut cmd = Command::new(exe); - cmd.args(["start", "--foreground"]); + cmd.args([ + "--config", + &config_path.to_string_lossy(), + "start", + "--foreground", + ]); // Pass verbose flag via environment to subprocess if verbose { @@ -72,12 +81,12 @@ pub fn execute(foreground: bool, verbose: bool) -> Result<()> { } #[tokio::main] -async fn run_server(verbose: bool) -> Result<()> { +async fn run_server(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { use std::io::IsTerminal; use crate::daemon::Server; use crate::infrastructure::pid::PidFile; - use crate::infrastructure::tracing::{TracingOutput, default_log_path, init_tracing}; + use crate::infrastructure::tracing::{TracingOutput, init_tracing}; use tracing::info; // When running interactively (stdout is a TTY), log to stdout @@ -85,25 +94,26 @@ async fn run_server(verbose: bool) -> Result<()> { let output = if std::io::stdout().is_terminal() { TracingOutput::Stdout } else { - TracingOutput::File(default_log_path()) + TracingOutput::File(paths.log_file.clone()) }; init_tracing(verbose, output); info!("Roxy daemon started"); - let pid_file = PidFile::new(); + let pid_file = PidFile::new(paths.pid_file.clone()); pid_file.write()?; // Handle Ctrl+C gracefully - let pid_file_cleanup = PidFile::new(); + let pid_path_for_cleanup = paths.pid_file.clone(); ctrlc::set_handler(move || { - let _ = pid_file_cleanup.remove(); + let cleanup = PidFile::new(pid_path_for_cleanup.clone()); + let _ = cleanup.remove(); std::process::exit(0); })?; println!("Starting Roxy daemon..."); - let server = Server::new()?; + let server = Server::new(config_path, paths)?; let result = server.run().await; pid_file.remove()?; diff --git a/src/cli/status.rs b/src/cli/status.rs index c1e4562..bab9f77 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,14 +1,17 @@ +use std::path::Path; + use anyhow::Result; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; use crate::infrastructure::network::get_lan_ip; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute() -> Result<()> { - let pid_file = PidFile::new(); - let config_store = ConfigStore::new(); - let cert_service = CertificateService::new(); +pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { + let pid_file = PidFile::new(paths.pid_file.clone()); + let config_store = ConfigStore::new(config_path.to_path_buf()); + let cert_service = CertificateService::new(paths); // Get LAN IP let lan_ip = get_lan_ip(); diff --git a/src/cli/stop.rs b/src/cli/stop.rs index 89dca44..24bae4d 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -1,9 +1,10 @@ use anyhow::{Result, bail}; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute() -> Result<()> { - let pid_file = PidFile::new(); +pub fn execute(paths: &RoxyPaths) -> Result<()> { + let pid_file = PidFile::new(paths.pid_file.clone()); let pid = match pid_file.get_running_pid()? { Some(pid) => pid, diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index 9f994e0..f7bbe8d 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -1,20 +1,23 @@ -use anyhow::Result; use std::fs; +use std::path::Path; use std::process::Command; +use anyhow::Result; + use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; use crate::infrastructure::dns::get_dns_service; +use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; -pub fn execute(force: bool) -> Result<()> { +pub fn execute(force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { if !force { println!("This will remove all Roxy configuration including:"); println!(" - Stop the running daemon"); println!(" - DNS configuration for *.roxy domains"); println!(" - All registered domains"); println!(" - All SSL certificates from system trust store"); - println!(" - All data in ~/.roxy/"); + println!(" - All data in {}/", paths.data_dir.display()); println!("\nRun with --force to confirm, or press Ctrl+C to cancel."); return Ok(()); } @@ -22,7 +25,7 @@ pub fn execute(force: bool) -> Result<()> { println!("Uninstalling Roxy...\n"); // Step 1: Stop daemon if running - let pid_file = PidFile::new(); + let pid_file = PidFile::new(paths.pid_file.clone()); if let Some(pid) = pid_file.get_running_pid()? { println!(" Stopping daemon (PID: {})...", pid); stop_daemon(pid)?; @@ -31,10 +34,10 @@ pub fn execute(force: bool) -> Result<()> { } // Step 2: Remove certificates from trust store - let config_store = ConfigStore::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); let domains = config_store.list_domains().unwrap_or_default(); - let cert_service = CertificateService::new(); + let cert_service = CertificateService::new(paths); if !domains.is_empty() { println!(" Removing {} domain certificate(s)...", domains.len()); @@ -62,14 +65,27 @@ pub fn execute(force: bool) -> Result<()> { println!(" DNS configuration removed."); } - // Step 4: Remove ~/.roxy/ directory entirely - let roxy_dir = config_store.config_dir(); - if roxy_dir.exists() { - println!(" Removing {}...", roxy_dir.display()); - fs::remove_dir_all(&roxy_dir)?; + // Step 4: Remove data directory entirely + if paths.data_dir.exists() { + println!(" Removing {}...", paths.data_dir.display()); + fs::remove_dir_all(&paths.data_dir)?; println!(" Directory removed."); } + // Step 5: Remove PID file + if paths.pid_file.exists() { + let _ = fs::remove_file(&paths.pid_file); + } + + // Step 6: Remove log directory + if let Some(log_dir) = paths.log_file.parent() + && log_dir.exists() + { + println!(" Removing {}...", log_dir.display()); + let _ = fs::remove_dir_all(log_dir); + println!(" Log directory removed."); + } + println!("\nRoxy uninstallation complete!"); println!("All configuration and certificates have been removed."); diff --git a/src/cli/unregister.rs b/src/cli/unregister.rs index fa338c1..c4dc682 100644 --- a/src/cli/unregister.rs +++ b/src/cli/unregister.rs @@ -1,14 +1,17 @@ +use std::path::Path; + use anyhow::{Result, bail}; use crate::domain::DomainName; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; -pub fn execute(domain: String, force: bool) -> Result<()> { +pub fn execute(domain: String, force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { let domain = DomainName::new(&domain)?; - let config_store = ConfigStore::new(); - let cert_service = CertificateService::new(); + let config_store = ConfigStore::new(config_path.to_path_buf()); + let cert_service = CertificateService::new(paths); // Check if domain exists let registration = config_store.get_domain(&domain)?; diff --git a/src/daemon/router.rs b/src/daemon/router.rs index 7524da6..6610f49 100644 --- a/src/daemon/router.rs +++ b/src/daemon/router.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use axum::{ @@ -22,8 +23,8 @@ pub struct AppState { } impl AppState { - pub fn new() -> anyhow::Result { - let config_store = ConfigStore::new(); + pub fn new(config_path: PathBuf) -> anyhow::Result { + let config_store = ConfigStore::new(config_path); let registrations = config_store.list_domains()?; let domains: HashMap = registrations diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 4b60aa1..db45ea7 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -1,4 +1,5 @@ use std::net::{Ipv4Addr, SocketAddr}; +use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; @@ -12,6 +13,7 @@ use super::tls::create_tls_acceptor; use crate::domain::DomainName; use crate::infrastructure::config::ConfigStore; use crate::infrastructure::network::get_lan_ip; +use crate::infrastructure::paths::RoxyPaths; pub struct Server { state: Arc, @@ -23,14 +25,14 @@ pub struct Server { } impl Server { - pub fn new() -> Result { - let config_store = ConfigStore::new(); + pub fn new(config_path: &Path, paths: &RoxyPaths) -> Result { + let config_store = ConfigStore::new(config_path.to_path_buf()); let config = config_store.load()?; // Validate config before starting config.validate()?; - let state = Arc::new(AppState::new()?); + let state = Arc::new(AppState::new(config_path.to_path_buf())?); // Get domains with HTTPS enabled let https_domains: Vec = config @@ -40,7 +42,7 @@ impl Server { .map(|d| d.domain.clone()) .collect(); - let tls_acceptor = create_tls_acceptor(&https_domains)?; + let tls_acceptor = create_tls_acceptor(&https_domains, &paths.certs_dir)?; // Get LAN IP for DNS responses (DNS server handles source-based resolution) let lan_ip = get_lan_ip(); diff --git a/src/daemon/tls.rs b/src/daemon/tls.rs index fd252ac..4d05f2b 100644 --- a/src/daemon/tls.rs +++ b/src/daemon/tls.rs @@ -11,7 +11,6 @@ use rustls::sign::CertifiedKey; use tokio_rustls::TlsAcceptor; use crate::domain::DomainName; -use crate::infrastructure::certs::CertificateGenerator; /// Custom certificate resolver that selects certificates based on SNI hostname #[derive(Debug)] @@ -34,22 +33,28 @@ impl ResolvesServerCert for DomainCertResolver { } /// Load all domain certificates into a single TLS acceptor with SNI -pub fn create_tls_acceptor(domains: &[DomainName]) -> Result> { +pub fn create_tls_acceptor( + domains: &[DomainName], + certs_dir: &Path, +) -> Result> { if domains.is_empty() { return Ok(None); } let mut certs_map = HashMap::new(); - let generator = CertificateGenerator::new(); - // Load all domain certificates + // Load all domain certificates directly from certs_dir for domain in domains { - let paths = generator - .get_paths(domain) - .ok_or_else(|| anyhow::anyhow!("No certificate found for {}", domain))?; + let domain_str = domain.as_str(); + let cert_path = certs_dir.join(format!("{}.crt", domain_str)); + let key_path = certs_dir.join(format!("{}.key", domain_str)); - let certs = load_certs(&paths.cert)?; - let key = load_private_key(&paths.key)?; + if !cert_path.exists() || !key_path.exists() { + anyhow::bail!("No certificate found for {}", domain); + } + + let certs = load_certs(&cert_path)?; + let key = load_private_key(&key_path)?; // Create a signing key using aws-lc-rs (default crypto provider) let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key) diff --git a/src/infrastructure/certs/ca.rs b/src/infrastructure/certs/ca.rs index 67b042b..6124d2d 100644 --- a/src/infrastructure/certs/ca.rs +++ b/src/infrastructure/certs/ca.rs @@ -13,31 +13,23 @@ use super::CertError; /// This CA is used to sign all domain certificates. Users only need to trust /// the CA once, and all domain certificates will be automatically trusted. pub struct RootCA { - roxy_dir: PathBuf, + data_dir: PathBuf, } impl RootCA { - pub fn new() -> Self { - let roxy_dir = dirs::home_dir() - .expect("Could not find home directory") - .join(".roxy"); - - Self { roxy_dir } - } - - /// Create a RootCA with a custom base directory (useful for testing) - pub fn with_base_dir(roxy_dir: PathBuf) -> Self { - Self { roxy_dir } + /// Create a RootCA with the given base directory + pub fn new(data_dir: PathBuf) -> Self { + Self { data_dir } } /// Path to the CA certificate pub fn cert_path(&self) -> PathBuf { - self.roxy_dir.join("ca.crt") + self.data_dir.join("ca.crt") } /// Path to the CA private key pub fn key_path(&self) -> PathBuf { - self.roxy_dir.join("ca.key") + self.data_dir.join("ca.key") } /// Check if the CA already exists @@ -48,8 +40,8 @@ impl RootCA { /// Generate a new Root CA certificate pub fn generate(&self) -> Result<(), CertError> { // Ensure directory exists - fs::create_dir_all(&self.roxy_dir).map_err(|e| CertError::WriteError { - path: self.roxy_dir.clone(), + fs::create_dir_all(&self.data_dir).map_err(|e| CertError::WriteError { + path: self.data_dir.clone(), source: e, })?; @@ -180,19 +172,13 @@ impl RootCA { } } -impl Default for RootCA { - fn default() -> Self { - Self::new() - } -} - #[cfg(test)] mod tests { use super::*; #[test] fn test_ca_paths() { - let ca = RootCA::new(); + let ca = RootCA::new(PathBuf::from("/tmp/test-roxy")); assert!(ca.cert_path().to_string_lossy().contains("ca.crt")); assert!(ca.key_path().to_string_lossy().contains("ca.key")); } diff --git a/src/infrastructure/certs/generator.rs b/src/infrastructure/certs/generator.rs index 222add8..560607f 100644 --- a/src/infrastructure/certs/generator.rs +++ b/src/infrastructure/certs/generator.rs @@ -17,35 +17,14 @@ pub struct Certificate { pub key_pem: String, } -/// Paths to certificate and key files -pub struct CertPaths { - pub cert: PathBuf, - pub key: PathBuf, -} - pub struct CertificateGenerator { base_dir: PathBuf, certs_dir: PathBuf, } impl CertificateGenerator { - pub fn new() -> Self { - let base_dir = dirs::home_dir() - .expect("Could not find home directory") - .join(".roxy"); - - let certs_dir = base_dir.join("certs"); - - Self { - base_dir, - certs_dir, - } - } - - /// Create a CertificateGenerator with a custom base directory (useful for testing) - #[cfg(test)] - pub fn with_base_dir(base_dir: PathBuf) -> Self { - let certs_dir = base_dir.join("certs"); + /// Create a CertificateGenerator with explicit directories + pub fn new(base_dir: PathBuf, certs_dir: PathBuf) -> Self { Self { base_dir, certs_dir, @@ -54,7 +33,7 @@ impl CertificateGenerator { /// Generate a new certificate for the given domain, signed by the Root CA pub fn generate(&self, domain: &DomainName) -> Result { - let ca = RootCA::with_base_dir(self.base_dir.clone()); + let ca = RootCA::new(self.base_dir.clone()); // Ensure CA exists if !ca.exists() { @@ -105,7 +84,7 @@ impl CertificateGenerator { } /// Save a certificate to disk - pub fn save(&self, cert: &Certificate) -> Result { + pub fn save(&self, cert: &Certificate) -> Result<(), CertError> { // Ensure certs directory exists fs::create_dir_all(&self.certs_dir).map_err(|e| CertError::WriteError { path: self.certs_dir.clone(), @@ -144,10 +123,7 @@ impl CertificateGenerator { })?; } - Ok(CertPaths { - cert: cert_path, - key: key_path, - }) + Ok(()) } /// Delete certificate files for a domain @@ -181,28 +157,6 @@ impl CertificateGenerator { let key_path = self.certs_dir.join(format!("{}.key", domain.as_str())); cert_path.exists() && key_path.exists() } - - /// Get paths to certificate files for a domain - pub fn get_paths(&self, domain: &DomainName) -> Option { - let domain_str = domain.as_str(); - let cert_path = self.certs_dir.join(format!("{}.crt", domain_str)); - let key_path = self.certs_dir.join(format!("{}.key", domain_str)); - - if cert_path.exists() && key_path.exists() { - Some(CertPaths { - cert: cert_path, - key: key_path, - }) - } else { - None - } - } -} - -impl Default for CertificateGenerator { - fn default() -> Self { - Self::new() - } } #[cfg(test)] @@ -215,14 +169,15 @@ mod tests { // Create an isolated test environment let temp_dir = TempDir::new().expect("Failed to create temp dir"); let base_dir = temp_dir.path().to_path_buf(); + let certs_dir = base_dir.join("certs"); // Generate a test CA in the temp directory - let ca = RootCA::with_base_dir(base_dir.clone()); + let ca = RootCA::new(base_dir.clone()); ca.generate().expect("Failed to generate test CA"); // Now test certificate generation let domain = DomainName::new("test.roxy").unwrap(); - let generator = CertificateGenerator::with_base_dir(base_dir); + let generator = CertificateGenerator::new(base_dir, certs_dir); let cert = generator.generate(&domain).unwrap(); diff --git a/src/infrastructure/certs/service.rs b/src/infrastructure/certs/service.rs index a66c45c..e389719 100644 --- a/src/infrastructure/certs/service.rs +++ b/src/infrastructure/certs/service.rs @@ -2,6 +2,7 @@ use super::ca::RootCA; use super::trust_store::get_trust_store; use super::{CertError, CertificateGenerator}; use crate::domain::DomainName; +use crate::infrastructure::paths::RoxyPaths; /// High-level service for certificate operations pub struct CertificateService { @@ -10,10 +11,11 @@ pub struct CertificateService { } impl CertificateService { - pub fn new() -> Self { + /// Create a new CertificateService with paths from RoxyPaths + pub fn new(paths: &RoxyPaths) -> Self { Self { - generator: CertificateGenerator::new(), - ca: RootCA::new(), + generator: CertificateGenerator::new(paths.data_dir.clone(), paths.certs_dir.clone()), + ca: RootCA::new(paths.data_dir.clone()), } } @@ -90,9 +92,3 @@ impl CertificateService { Ok(()) } } - -impl Default for CertificateService { - fn default() -> Self { - Self::new() - } -} diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index cb0a7df..c86ebb2 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1,4 +1,5 @@ use crate::domain::{DomainName, DomainRegistration}; +use crate::infrastructure::paths::RoxyPaths; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -108,11 +109,56 @@ impl DaemonConfig { } } +fn default_data_dir() -> PathBuf { + PathBuf::from("/etc/roxy") +} + +fn default_pid_file() -> PathBuf { + PathBuf::from("/var/run/roxy.pid") +} + +fn default_log_file() -> PathBuf { + PathBuf::from("/var/log/roxy/roxy.log") +} + +fn default_certs_dir() -> PathBuf { + PathBuf::from("/etc/roxy/certs") +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PathsConfig { + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + + #[serde(default = "default_pid_file")] + pub pid_file: PathBuf, + + #[serde(default = "default_log_file")] + pub log_file: PathBuf, + + #[serde(default = "default_certs_dir")] + pub certs_dir: PathBuf, +} + +impl Default for PathsConfig { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + pid_file: default_pid_file(), + log_file: default_log_file(), + certs_dir: default_certs_dir(), + } + } +} + #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Config { #[serde(default)] pub daemon: DaemonConfig, + #[serde(default)] + pub paths: PathsConfig, + #[serde(default)] pub domains: HashMap, } @@ -129,6 +175,16 @@ impl Config { Ok(()) } + + /// Convert the paths config section into a resolved RoxyPaths + pub fn to_roxy_paths(&self) -> RoxyPaths { + RoxyPaths { + data_dir: self.paths.data_dir.clone(), + pid_file: self.paths.pid_file.clone(), + log_file: self.paths.log_file.clone(), + certs_dir: self.paths.certs_dir.clone(), + } + } } pub struct ConfigStore { @@ -136,24 +192,16 @@ pub struct ConfigStore { } impl ConfigStore { - pub fn new() -> Self { - let config_dir = dirs::home_dir() - .expect("Could not find home directory") - .join(".roxy"); - - Self { - path: config_dir.join("config.toml"), - } - } - - pub fn config_dir(&self) -> PathBuf { - self.path.parent().unwrap().to_path_buf() + /// Create a new ConfigStore pointing at the given config file path + pub fn new(path: PathBuf) -> Self { + Self { path } } fn ensure_config_dir(&self) -> Result<(), ConfigError> { - let dir = self.config_dir(); - if !dir.exists() { - fs::create_dir_all(&dir)?; + if let Some(dir) = self.path.parent() + && !dir.exists() + { + fs::create_dir_all(dir)?; } Ok(()) } @@ -226,9 +274,3 @@ impl ConfigStore { Ok(config.domains.into_values().collect()) } } - -impl Default for ConfigStore { - fn default() -> Self { - Self::new() - } -} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 1a013e6..c7192c9 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -2,5 +2,6 @@ pub mod certs; pub mod config; pub mod dns; pub mod network; +pub mod paths; pub mod pid; pub mod tracing; diff --git a/src/infrastructure/paths.rs b/src/infrastructure/paths.rs new file mode 100644 index 0000000..0061540 --- /dev/null +++ b/src/infrastructure/paths.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +/// All resolved paths needed by Roxy components. +/// Loaded once from config, then passed to components via DI. +#[derive(Debug, Clone)] +pub struct RoxyPaths { + pub data_dir: PathBuf, + pub pid_file: PathBuf, + pub log_file: PathBuf, + pub certs_dir: PathBuf, +} diff --git a/src/infrastructure/pid.rs b/src/infrastructure/pid.rs index 30411a9..4d05685 100644 --- a/src/infrastructure/pid.rs +++ b/src/infrastructure/pid.rs @@ -8,18 +8,16 @@ pub struct PidFile { } impl PidFile { - pub fn new() -> Self { - let path = dirs::home_dir() - .expect("Could not find home directory") - .join(".roxy") - .join("roxy.pid"); + pub fn new(path: PathBuf) -> Self { Self { path } } /// Write current process PID to file pub fn write(&self) -> Result<()> { let pid = process::id(); - fs::create_dir_all(self.path.parent().unwrap())?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&self.path, pid.to_string()).context("Failed to write PID file")?; Ok(()) } @@ -69,9 +67,3 @@ fn process_exists(pid: u32) -> bool { .map(|o| o.status.success()) .unwrap_or(false) } - -impl Default for PidFile { - fn default() -> Self { - Self::new() - } -} diff --git a/src/infrastructure/tracing.rs b/src/infrastructure/tracing.rs index 7166adf..d83de2f 100644 --- a/src/infrastructure/tracing.rs +++ b/src/infrastructure/tracing.rs @@ -11,15 +11,6 @@ pub enum TracingOutput { File(PathBuf), } -/// Returns the default log file path: ~/.roxy/logs/roxy.log -pub fn default_log_path() -> PathBuf { - dirs::home_dir() - .expect("Could not find home directory") - .join(".roxy") - .join("logs") - .join("roxy.log") -} - /// Initialize tracing based on configuration /// Priority: ROXY_LOG env > verbose flag > default (info) pub fn init_tracing(verbose: bool, output: TracingOutput) { diff --git a/src/main.rs b/src/main.rs index edb2574..871497d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + use anyhow::Result; use clap::{Parser, Subcommand}; @@ -6,6 +8,9 @@ mod daemon; mod domain; mod infrastructure; +use infrastructure::config::{Config, ConfigStore}; +use infrastructure::paths::RoxyPaths; + #[derive(Parser)] #[command(name = "roxy")] #[command(about = "Local development proxy with custom .roxy domains and HTTPS")] @@ -14,6 +19,10 @@ mod infrastructure; after_help = concat!("Heads up: Roxy is still finding her feet (v", env!("CARGO_PKG_VERSION"), ").\nThings may shift around. If something bites, let me know!\nhttps://github.com/rbas/roxy/issues") )] struct Cli { + /// Path to the config file + #[arg(short, long, global = true, default_value = "/etc/roxy/config.toml")] + config: PathBuf, + /// Enable verbose output #[arg(short, long, global = true)] verbose: bool, @@ -129,33 +138,51 @@ enum RouteCommands { }, } +/// Load config from file, or return defaults if the file doesn't exist. +/// For `install`, the config file may not exist yet, so defaults are fine. +fn load_config_and_paths(config_path: &Path) -> Result<(Config, RoxyPaths)> { + let config_store = ConfigStore::new(config_path.to_path_buf()); + let config = config_store.load()?; + let paths = config.to_roxy_paths(); + Ok((config, paths)) +} + fn main() -> Result<()> { let cli = Cli::parse(); + let config_path = &cli.config; + + let (config, paths) = load_config_and_paths(config_path)?; match cli.command { - Commands::Install => cli::install::execute(), - Commands::Uninstall { force } => cli::uninstall::execute(force), - Commands::Register { domain, route } => cli::register::execute(domain, route), - Commands::Unregister { domain, force } => cli::unregister::execute(domain, force), + Commands::Install => cli::install::execute(config_path, &paths, &config), + Commands::Uninstall { force } => cli::uninstall::execute(force, config_path, &paths), + Commands::Register { domain, route } => { + cli::register::execute(domain, route, config_path, &paths) + } + Commands::Unregister { domain, force } => { + cli::unregister::execute(domain, force, config_path, &paths) + } Commands::Route { command } => match command { RouteCommands::Add { domain, path, target, - } => cli::route::add(domain, path, target), - RouteCommands::Remove { domain, path } => cli::route::remove(domain, path), - RouteCommands::List { domain } => cli::route::list(domain), + } => cli::route::add(domain, path, target, config_path), + RouteCommands::Remove { domain, path } => cli::route::remove(domain, path, config_path), + RouteCommands::List { domain } => cli::route::list(domain, config_path), }, - Commands::List => cli::list::execute(), - Commands::Start { foreground } => cli::start::execute(foreground, cli.verbose), - Commands::Stop => cli::stop::execute(), - Commands::Restart => cli::restart::execute(), - Commands::Status => cli::status::execute(), + Commands::List => cli::list::execute(config_path, &paths), + Commands::Start { foreground } => { + cli::start::execute(foreground, cli.verbose, config_path, &paths, &config) + } + Commands::Stop => cli::stop::execute(&paths), + Commands::Restart => cli::restart::execute(cli.verbose, config_path, &paths), + Commands::Status => cli::status::execute(config_path, &paths), Commands::Logs { lines, clear, follow, - } => cli::logs::execute(lines, clear, follow), - Commands::Reload => cli::reload::execute(), + } => cli::logs::execute(lines, clear, follow, &paths), + Commands::Reload => cli::reload::execute(cli.verbose, config_path, &paths), } } From 704c917d56c78e33c3cf09df6050b1f63db7cfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Mon, 9 Feb 2026 21:39:29 +0100 Subject: [PATCH 2/4] refactor: Clean the code --- docs/README.md | 86 ++++++++++++++++++++----- scripts/formula.rb.template | 6 +- src/cli/install.rs | 19 ++++-- src/cli/reload.rs | 5 +- src/cli/restart.rs | 3 +- src/cli/start.rs | 14 ++-- src/cli/uninstall.rs | 8 ++- src/daemon/router.rs | 9 +-- src/daemon/server.rs | 11 ++-- src/daemon/tls.rs | 28 +++----- src/domain/value_objects/path_prefix.rs | 6 +- src/domain/value_objects/route.rs | 9 ++- src/infrastructure/config/mod.rs | 54 +--------------- src/infrastructure/paths.rs | 36 ++++++++++- src/main.rs | 2 +- 15 files changed, 166 insertions(+), 130 deletions(-) diff --git a/docs/README.md b/docs/README.md index 8e97e57..d6ee147 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,7 @@ open https://myapp.roxy | `sudo roxy install` | Initial setup | | `sudo roxy uninstall [--force]` | Full cleanup | | `sudo roxy register ...` | Register domain | -| `roxy unregister ` | Remove domain | +| `sudo roxy unregister ` | Remove domain | | `roxy list` | Show all domains | | `sudo roxy route add ...` | Add route to domain | | `roxy route remove ...` | Remove route from domain | @@ -42,7 +42,9 @@ open https://myapp.roxy | `roxy status` | Show daemon status | | `roxy logs [-n N] [-f]` | View or follow daemon logs | -**Note:** Commands that modify system configuration (CA certs, DNS) or control the daemon (runs on ports 80/443) require `sudo`. +**Note:** Commands that modify system configuration +(CA certs, DNS) or control the daemon (runs on ports +80/443) require `sudo`. ## Route Targets @@ -87,16 +89,18 @@ roxy route list app.roxy ## Files and Directories ```text -~/.roxy/ +/etc/roxy/ ├── config.toml # Main configuration -├── roxy.pid # PID file (when daemon runs) -├── certs/ -│ ├── ca.key # Root CA private key -│ ├── ca.crt # Root CA certificate -│ ├── .key # Per-domain private key -│ └── .crt # Per-domain certificate -└── logs/ - └── roxy.log # Daemon log file +├── ca.key # Root CA private key +├── ca.crt # Root CA certificate +└── certs/ + ├── .key # Per-domain private key + └── .crt # Per-domain certificate + +/var/run/roxy.pid # PID file (when daemon runs) + +/var/log/roxy/ +└── roxy.log # Daemon log file ``` macOS DNS resolver file (created by `roxy install`): @@ -108,10 +112,29 @@ macOS DNS resolver file (created by `roxy install`): This tells macOS 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 + +If you installed Roxy via Homebrew, use `brew services` +to start it automatically at boot: + +```bash +# Start now and auto-start at boot +sudo brew services start roxy + +# Stop auto-start +sudo brew services stop roxy +``` + +When managed by `brew services`, Roxy runs in foreground +mode and launchd handles process supervision. + ## Daemon: Foreground vs Background **Background** (default) — forks to the background, -writes a PID file, logs to `~/.roxy/logs/roxy.log`: +writes a PID file, logs to `/var/log/roxy/roxy.log`: ```bash sudo roxy start @@ -141,7 +164,7 @@ Change the log level (highest priority first): `ROXY_LOG=debug sudo roxy start` 2. **CLI flag** — `sudo roxy start --verbose` (sets debug level) -3. **Config file** — edit `~/.roxy/config.toml`: +3. **Config file** — edit `/etc/roxy/config.toml`: ```toml [daemon] @@ -152,9 +175,25 @@ Change the log level (highest priority first): Available levels: `error`, `warn`, `info`, `debug`. +## Global Options + +All commands accept these global flags: + +| Flag | Default | Description | +| ---- | ------- | ----------- | +| `-c`, `--config ` | `/etc/roxy/config.toml` | Config file | +| `-v`, `--verbose` | off | Enable debug output | + +Example using a custom config: + +```bash +sudo roxy -c /opt/roxy/config.toml start +``` + ## Configuration -The configuration lives in `~/.roxy/config.toml`. +The configuration lives in `/etc/roxy/config.toml` +(override with `--config`). ### Daemon Section @@ -190,6 +229,21 @@ target = "127.0.0.1:3001" Domain names must end with `.roxy` and can contain letters, numbers, hyphens, and dots (for subdomains). +### Paths Section + +Override where Roxy stores its data: + +```toml +[paths] +data_dir = "/etc/roxy" +pid_file = "/var/run/roxy.pid" +log_file = "/var/log/roxy/roxy.log" +certs_dir = "/etc/roxy/certs" +``` + +The values above are the defaults. You only need this +section if you want different locations. + ## Using Roxy with Docker Roxy runs on the host, so containers need to know how @@ -265,8 +319,8 @@ sudo lsof -i :443 sudo lsof -i :1053 ``` -Stop the conflicting service or configure Roxy to use different ports -in `~/.roxy/config.toml`. +Stop the conflicting service or configure Roxy to use +different ports in `/etc/roxy/config.toml`. ### Backend Service Not Responding diff --git a/scripts/formula.rb.template b/scripts/formula.rb.template index a8e3281..6af7fc3 100644 --- a/scripts/formula.rb.template +++ b/scripts/formula.rb.template @@ -13,11 +13,11 @@ class Roxy < Formula service do name macos: "cz.rbas.roxy" - run [opt_bin/"roxy", "start", "--foreground"] + run [opt_bin/"roxy", "--config", "/etc/roxy/config.toml", "start", "--foreground"] keep_alive true require_root true - log_path var/"log/roxy/roxy.log" - error_log_path var/"log/roxy/roxy.log" + log_path "/var/log/roxy/roxy.log" + error_log_path "/var/log/roxy/roxy.log" end def caveats diff --git a/src/cli/install.rs b/src/cli/install.rs index 01c71f9..a522d65 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,6 +1,6 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::{Config, ConfigStore}; @@ -21,12 +21,23 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths, config: &Config) -> Result } // Ensure data directory exists - std::fs::create_dir_all(&paths.data_dir)?; - std::fs::create_dir_all(&paths.certs_dir)?; + std::fs::create_dir_all(&paths.data_dir).with_context(|| { + format!( + "Failed to create data directory: {}", + paths.data_dir.display() + ) + })?; + std::fs::create_dir_all(&paths.certs_dir).with_context(|| { + format!( + "Failed to create certs directory: {}", + paths.certs_dir.display() + ) + })?; // Ensure log directory exists if let Some(log_dir) = paths.log_file.parent() { - std::fs::create_dir_all(log_dir)?; + std::fs::create_dir_all(log_dir) + .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?; } // Write default config if it doesn't exist diff --git a/src/cli/reload.rs b/src/cli/reload.rs index 9aa3251..17202a1 100644 --- a/src/cli/reload.rs +++ b/src/cli/reload.rs @@ -24,9 +24,10 @@ pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<( // Re-load config from disk to pick up changes let config_store = ConfigStore::new(config_path.to_path_buf()); let fresh_config = config_store.load()?; + let fresh_paths = fresh_config.paths.clone(); - // Start the daemon with fresh config - super::start::execute(false, verbose, config_path, paths, &fresh_config)?; + // Start the daemon with fresh config and paths + super::start::execute(false, verbose, config_path, &fresh_paths, &fresh_config)?; println!("Daemon reloaded with updated configuration."); Ok(()) diff --git a/src/cli/restart.rs b/src/cli/restart.rs index d2d30aa..11c37bb 100644 --- a/src/cli/restart.rs +++ b/src/cli/restart.rs @@ -19,7 +19,8 @@ pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<( // Re-load config from disk to pick up changes let config_store = ConfigStore::new(config_path.to_path_buf()); let fresh_config = config_store.load()?; + let fresh_paths = fresh_config.paths.clone(); println!("Starting Roxy daemon..."); - start::execute(false, verbose, config_path, paths, &fresh_config) + start::execute(false, verbose, config_path, &fresh_paths, &fresh_config) } diff --git a/src/cli/start.rs b/src/cli/start.rs index cd1f92e..03bc36e 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use std::env; use std::process::{Command, Stdio}; -use crate::infrastructure::config::Config; +use crate::infrastructure::config::{Config, ConfigStore}; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; @@ -104,16 +104,20 @@ async fn run_server(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Res pid_file.write()?; // Handle Ctrl+C gracefully - let pid_path_for_cleanup = paths.pid_file.clone(); + let cleanup_pid = PidFile::new(paths.pid_file.clone()); ctrlc::set_handler(move || { - let cleanup = PidFile::new(pid_path_for_cleanup.clone()); - let _ = cleanup.remove(); + let _ = cleanup_pid.remove(); std::process::exit(0); })?; println!("Starting Roxy daemon..."); - let server = Server::new(config_path, paths)?; + // Load config fresh from disk (this path is used by the forked + // subprocess, so it must re-read from the config file) + let config_store = ConfigStore::new(config_path.to_path_buf()); + let config = config_store.load()?; + + let server = Server::new(&config, paths)?; let result = server.run().await; pid_file.remove()?; diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index f7bbe8d..2f33289 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -102,9 +102,13 @@ fn stop_daemon(pid: u32) -> Result<()> { std::thread::sleep(std::time::Duration::from_millis(500)); // Check if still running, force kill if needed - let status = Command::new("kill").args(["-0", &pid.to_string()]).output(); + let still_running = Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); - if status.is_ok() && status.unwrap().status.success() { + if still_running { // Still running, force kill Command::new("kill") .args(["-KILL", &pid.to_string()]) diff --git a/src/daemon/router.rs b/src/daemon/router.rs index 6610f49..55e8086 100644 --- a/src/daemon/router.rs +++ b/src/daemon/router.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; use axum::{ @@ -12,7 +11,6 @@ use axum::{ use tracing::{debug, info}; use crate::domain::{DomainRegistration, RouteTarget}; -use crate::infrastructure::config::ConfigStore; use super::proxy::proxy_request; use super::static_files::serve_static; @@ -23,16 +21,13 @@ pub struct AppState { } impl AppState { - pub fn new(config_path: PathBuf) -> anyhow::Result { - let config_store = ConfigStore::new(config_path); - let registrations = config_store.list_domains()?; - + pub fn new(registrations: Vec) -> Self { let domains: HashMap = registrations .into_iter() .map(|r| (r.domain.as_str().to_string(), r)) .collect(); - Ok(Self { domains }) + Self { domains } } pub fn get_domain(&self, host: &str) -> Option<&DomainRegistration> { diff --git a/src/daemon/server.rs b/src/daemon/server.rs index db45ea7..72f7d92 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -1,5 +1,4 @@ use std::net::{Ipv4Addr, SocketAddr}; -use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; @@ -11,7 +10,7 @@ use super::dns_server::DnsServer; use super::router::{AppState, create_router}; use super::tls::create_tls_acceptor; use crate::domain::DomainName; -use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::config::Config; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; @@ -25,14 +24,12 @@ pub struct Server { } impl Server { - pub fn new(config_path: &Path, paths: &RoxyPaths) -> Result { - let config_store = ConfigStore::new(config_path.to_path_buf()); - let config = config_store.load()?; - + pub fn new(config: &Config, paths: &RoxyPaths) -> Result { // Validate config before starting config.validate()?; - let state = Arc::new(AppState::new(config_path.to_path_buf())?); + let registrations: Vec<_> = config.domains.values().cloned().collect(); + let state = Arc::new(AppState::new(registrations)); // Get domains with HTTPS enabled let https_domains: Vec = config diff --git a/src/daemon/tls.rs b/src/daemon/tls.rs index 4d05f2b..a1ffb2c 100644 --- a/src/daemon/tls.rs +++ b/src/daemon/tls.rs @@ -9,26 +9,27 @@ use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::server::ResolvesServerCert; use rustls::sign::CertifiedKey; use tokio_rustls::TlsAcceptor; +use tracing::warn; use crate::domain::DomainName; -/// Custom certificate resolver that selects certificates based on SNI hostname +/// Custom certificate resolver that selects certificates based on SNI hostname. +/// Returns None for unknown domains, causing a clean TLS failure rather than +/// serving a mismatched certificate. #[derive(Debug)] struct DomainCertResolver { certs: HashMap>, - fallback: Arc, } impl ResolvesServerCert for DomainCertResolver { fn resolve(&self, client_hello: rustls::server::ClientHello) -> Option> { - // Get the SNI hostname from the client hello let hostname = client_hello.server_name()?; - // Look up certificate for this domain, fall back to first cert if not found - self.certs - .get(hostname) - .cloned() - .or_else(|| Some(self.fallback.clone())) + let cert = self.certs.get(hostname).cloned(); + if cert.is_none() { + warn!(hostname = %hostname, "TLS: no certificate for domain"); + } + cert } } @@ -64,16 +65,7 @@ pub fn create_tls_acceptor( certs_map.insert(domain.as_str().to_string(), certified_key); } - // Use the first domain's cert as fallback - let fallback = certs_map - .get(domains[0].as_str()) - .ok_or_else(|| anyhow::anyhow!("No fallback certificate available"))? - .clone(); - - let resolver = Arc::new(DomainCertResolver { - certs: certs_map, - fallback, - }); + let resolver = Arc::new(DomainCertResolver { certs: certs_map }); let config = ServerConfig::builder() .with_no_client_auth() diff --git a/src/domain/value_objects/path_prefix.rs b/src/domain/value_objects/path_prefix.rs index 0ff727c..500f971 100644 --- a/src/domain/value_objects/path_prefix.rs +++ b/src/domain/value_objects/path_prefix.rs @@ -55,14 +55,10 @@ impl PathPrefix { } } + #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { self.0.len() } - - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } } impl fmt::Display for PathPrefix { diff --git a/src/domain/value_objects/route.rs b/src/domain/value_objects/route.rs index 0c9894e..15c05b5 100644 --- a/src/domain/value_objects/route.rs +++ b/src/domain/value_objects/route.rs @@ -30,16 +30,15 @@ pub enum RouteTargetError { } #[derive(Debug, Error)] -#[allow(clippy::enum_variant_names)] pub enum RouteError { #[error("Invalid path prefix: {0}")] - InvalidPathPrefix(#[from] PathPrefixError), + PathPrefix(#[from] PathPrefixError), #[error("Invalid target: {0}")] - InvalidTarget(#[from] RouteTargetError), + Target(#[from] RouteTargetError), #[error("Invalid route format: expected 'PATH=TARGET', got '{0}'")] - InvalidFormat(String), + Format(String), } impl RouteTarget { @@ -111,7 +110,7 @@ impl Route { pub fn parse(s: &str) -> Result { let (path_str, target_str) = s .split_once('=') - .ok_or_else(|| RouteError::InvalidFormat(s.to_string()))?; + .ok_or_else(|| RouteError::Format(s.to_string()))?; let path = PathPrefix::new(path_str)?; let target = RouteTarget::parse(target_str)?; diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index c86ebb2..f3610ff 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -109,55 +109,13 @@ impl DaemonConfig { } } -fn default_data_dir() -> PathBuf { - PathBuf::from("/etc/roxy") -} - -fn default_pid_file() -> PathBuf { - PathBuf::from("/var/run/roxy.pid") -} - -fn default_log_file() -> PathBuf { - PathBuf::from("/var/log/roxy/roxy.log") -} - -fn default_certs_dir() -> PathBuf { - PathBuf::from("/etc/roxy/certs") -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PathsConfig { - #[serde(default = "default_data_dir")] - pub data_dir: PathBuf, - - #[serde(default = "default_pid_file")] - pub pid_file: PathBuf, - - #[serde(default = "default_log_file")] - pub log_file: PathBuf, - - #[serde(default = "default_certs_dir")] - pub certs_dir: PathBuf, -} - -impl Default for PathsConfig { - fn default() -> Self { - Self { - data_dir: default_data_dir(), - pid_file: default_pid_file(), - log_file: default_log_file(), - certs_dir: default_certs_dir(), - } - } -} - #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Config { #[serde(default)] pub daemon: DaemonConfig, #[serde(default)] - pub paths: PathsConfig, + pub paths: RoxyPaths, #[serde(default)] pub domains: HashMap, @@ -175,16 +133,6 @@ impl Config { Ok(()) } - - /// Convert the paths config section into a resolved RoxyPaths - pub fn to_roxy_paths(&self) -> RoxyPaths { - RoxyPaths { - data_dir: self.paths.data_dir.clone(), - pid_file: self.paths.pid_file.clone(), - log_file: self.paths.log_file.clone(), - certs_dir: self.paths.certs_dir.clone(), - } - } } pub struct ConfigStore { diff --git a/src/infrastructure/paths.rs b/src/infrastructure/paths.rs index 0061540..c1b78a4 100644 --- a/src/infrastructure/paths.rs +++ b/src/infrastructure/paths.rs @@ -1,11 +1,45 @@ use std::path::PathBuf; +fn default_data_dir() -> PathBuf { + PathBuf::from("/etc/roxy") +} + +fn default_pid_file() -> PathBuf { + PathBuf::from("/var/run/roxy.pid") +} + +fn default_log_file() -> PathBuf { + PathBuf::from("/var/log/roxy/roxy.log") +} + +fn default_certs_dir() -> PathBuf { + PathBuf::from("/etc/roxy/certs") +} + /// All resolved paths needed by Roxy components. /// Loaded once from config, then passed to components via DI. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RoxyPaths { + #[serde(default = "default_data_dir")] pub data_dir: PathBuf, + + #[serde(default = "default_pid_file")] pub pid_file: PathBuf, + + #[serde(default = "default_log_file")] pub log_file: PathBuf, + + #[serde(default = "default_certs_dir")] pub certs_dir: PathBuf, } + +impl Default for RoxyPaths { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + pid_file: default_pid_file(), + log_file: default_log_file(), + certs_dir: default_certs_dir(), + } + } +} diff --git a/src/main.rs b/src/main.rs index 871497d..c791b64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,7 +143,7 @@ enum RouteCommands { fn load_config_and_paths(config_path: &Path) -> Result<(Config, RoxyPaths)> { let config_store = ConfigStore::new(config_path.to_path_buf()); let config = config_store.load()?; - let paths = config.to_roxy_paths(); + let paths = config.paths.clone(); Ok((config, paths)) } From 82b054441667050e1003eb1f3fd21693dc0acf53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Tue, 10 Feb 2026 09:32:12 +0100 Subject: [PATCH 3/4] fix: Clean code smells --- .github/workflows/release.yml | 3 +-- scripts/formula.rb.template | 10 ++++++++-- src/cli/start.rs | 10 ++++------ src/cli/uninstall.rs | 6 ++++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7abfce2..2ad7368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,7 @@ name: Release on: push: tags: - - "v*" - - "*.*.*" + - "v*.*.*" workflow_dispatch: inputs: tag: diff --git a/scripts/formula.rb.template b/scripts/formula.rb.template index 6af7fc3..69d9296 100644 --- a/scripts/formula.rb.template +++ b/scripts/formula.rb.template @@ -4,8 +4,14 @@ class Roxy < Formula license "MIT" version "__VERSION__" - url "__URL__" - sha256 "__SHA256__" + on_macos do + if Hardware::CPU.arm? + url "__URL__" + sha256 "__SHA256__" + else + odie "Roxy currently only supports Apple Silicon (ARM64). Intel Macs are not yet supported." + end + end def install bin.install "roxy" diff --git a/src/cli/start.rs b/src/cli/start.rs index 03bc36e..7c9f59f 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -39,12 +39,10 @@ pub fn execute( let exe = env::current_exe()?; let mut cmd = Command::new(exe); - cmd.args([ - "--config", - &config_path.to_string_lossy(), - "start", - "--foreground", - ]); + cmd.arg("--config") + .arg(config_path) + .arg("start") + .arg("--foreground"); // Pass verbose flag via environment to subprocess if verbose { diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index 2f33289..c4bed7f 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -74,7 +74,9 @@ pub fn execute(force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> // Step 5: Remove PID file if paths.pid_file.exists() { - let _ = fs::remove_file(&paths.pid_file); + println!(" Removing PID file..."); + fs::remove_file(&paths.pid_file)?; + println!(" PID file removed."); } // Step 6: Remove log directory @@ -82,7 +84,7 @@ pub fn execute(force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> && log_dir.exists() { println!(" Removing {}...", log_dir.display()); - let _ = fs::remove_dir_all(log_dir); + fs::remove_dir_all(log_dir)?; println!(" Log directory removed."); } From 7ecea8c9fe7774e39a372e776a214dcc65aa4e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Tue, 10 Feb 2026 10:05:53 +0100 Subject: [PATCH 4/4] fix: Do not fail uninstall if the file/folders does not exist --- src/cli/uninstall.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index c4bed7f..ca50d15 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -72,19 +72,15 @@ pub fn execute(force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> println!(" Directory removed."); } - // Step 5: Remove PID file - if paths.pid_file.exists() { - println!(" Removing PID file..."); - fs::remove_file(&paths.pid_file)?; + // Step 5: Remove PID file (if it still exists) + if fs::remove_file(&paths.pid_file).is_ok() { println!(" PID file removed."); } // Step 6: Remove log directory if let Some(log_dir) = paths.log_file.parent() - && log_dir.exists() + && fs::remove_dir_all(log_dir).is_ok() { - println!(" Removing {}...", log_dir.display()); - fs::remove_dir_all(log_dir)?; println!(" Log directory removed."); }