From 2ae42104b9e06c87f1d1ee2e49ad6762e4261650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Thu, 20 Aug 2026 10:49:43 +0200 Subject: [PATCH] refactor: run roxy daemon without root Use user-owned state, socket-activated privileged ports, dynamic TLS certificates, and live configuration reloads. Migration imports /etc/roxy settings into the user configuration, copies the legacy CA, replaces the root daemon and old services, and retains /etc/roxy as a backup. --- Cargo.lock | 1 + Cargo.toml | 4 + README.md | 67 ++-- docs/README.md | 204 ++++++----- docs/docker.md | 24 +- docs/linux.md | 47 +-- scripts/formula.rb.template | 15 +- scripts/test-brew.sh | 23 +- src/application/list_all_domains.rs | 24 +- src/application/ports/certificate_manager.rs | 11 - src/application/ports/system_setup.rs | 2 +- src/application/register_domain.rs | 63 +--- src/application/restart_daemon.rs | 45 +-- src/application/stop_daemon.rs | 2 +- src/application/testkit.rs | 63 +--- src/application/uninstall.rs | 34 +- src/application/unregister_domain.rs | 54 +-- src/cli/context.rs | 9 + src/cli/install.rs | 68 +++- src/cli/logs.rs | 2 +- src/cli/register.rs | 24 +- src/cli/reload.rs | 19 +- src/cli/route.rs | 4 +- src/cli/start.rs | 18 + src/cli/status.rs | 2 +- src/cli/uninstall.rs | 2 + src/cli/unregister.rs | 13 +- src/config.rs | 136 +++++++- src/daemon/lifecycle.rs | 12 +- src/daemon/mgmt_socket.rs | 34 +- src/daemon/server.rs | 68 ++-- src/daemon/tls.rs | 191 ++++++----- src/domain/registration.rs | 10 +- src/infrastructure/certs/ca.rs | 32 +- src/infrastructure/certs/generator.rs | 219 +----------- src/infrastructure/certs/mod.rs | 51 +-- src/infrastructure/certs/service.rs | 55 +-- src/infrastructure/config/dto.rs | 7 +- src/infrastructure/config/mod.rs | 4 +- src/infrastructure/filesystem.rs | 8 - src/infrastructure/listeners.rs | 178 ++++++++++ src/infrastructure/mod.rs | 2 + src/infrastructure/process/unix.rs | 7 +- src/infrastructure/service/linux.rs | 128 +++++++ src/infrastructure/service/macos.rs | 162 +++++++++ src/infrastructure/service/mod.rs | 339 +++++++++++++++++++ src/infrastructure/tracing.rs | 6 +- src/main.rs | 49 ++- 48 files changed, 1527 insertions(+), 1015 deletions(-) create mode 100644 src/infrastructure/listeners.rs create mode 100644 src/infrastructure/service/linux.rs create mode 100644 src/infrastructure/service/macos.rs create mode 100644 src/infrastructure/service/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 62e17ca..399472f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,6 +1101,7 @@ dependencies = [ "humantime", "hyper", "hyper-util", + "libc", "rcgen", "rustls", "serde", diff --git a/Cargo.toml b/Cargo.toml index 52fc50e..216c6ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "roxy" version = "1.0.2" edition = "2024" +[build] +rustc-wrapper = "sccache" + [dependencies] clap = { version = "4", features = ["derive"] } clap_complete = "4" @@ -52,6 +55,7 @@ tokio-util = { version = "0.7", features = ["rt"] } # Utilities humantime = "2.1" +libc = "0.2" # Tracing tracing = "0.1" diff --git a/README.md b/README.md index 9e94429..ab17234 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,13 @@ HTTPS for every local project. brew tap rbas/roxy brew install roxy -# 2. One-time setup (creates Root CA, configures DNS) +# 2. One-time setup (CA, DNS, and unprivileged system service) sudo roxy install # 3. Register your first project roxy register myapp.roxy --route "/=3000" --route "/api=3001" -# 4. Start the proxy -sudo roxy start - -# 5. Open in browser +# 4. Open in browser (the proxy is already running) open https://myapp.roxy # on macOS xdg-open https://myapp.roxy # on linux ``` @@ -242,10 +239,12 @@ INFO WebSocket connection closed target=127.0.0.1:3000 duration_ms=45230 INFO DNS query domain=myapp.roxy qtype=A response=127.0.0.1 ``` -**Need debugging details?** Turn on verbose mode: +**Need debugging details?** Set `daemon.log_level = "debug"` in +your user configuration, restart Roxy, then follow the log: ```bash -sudo roxy start --verbose +roxy restart +roxy logs -f ``` ```text @@ -287,11 +286,11 @@ roxy unregister myapp.roxy # List all registered domains roxy list -# Daemon control -sudo roxy start # Start in background -sudo roxy start --foreground # Start in foreground -sudo roxy stop -sudo roxy restart +# Daemon control (no sudo after installation) +roxy start +roxy stop +roxy restart +roxy reload roxy status # View logs @@ -302,23 +301,10 @@ 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 -``` +`sudo roxy install` installs a launchd service on macOS or systemd socket and +service units on Linux. The operating system owns ports 80 and 443 and passes +the listeners to Roxy, while the Roxy process itself runs as the developer who +performed the installation. No separate `brew services` setup is needed. ## How It Works @@ -328,16 +314,16 @@ sudo brew services stop roxy - Creates a trusted Root Certificate Authority (CA) - Adds it to your system trust store (macOS Keychain / Linux ca-certificates) - Configures DNS to resolve `.roxy` domains (macOS `/etc/resolver/` / Linux systemd-resolved) + - Installs socket activation for ports 80/443 and starts Roxy as your user - **⚠️ Restart your browser** after first install for certificates to be recognized 2. **`roxy register `** - - Generates an SSL certificate signed by your Root CA - Saves your routing configuration (which paths go to which ports/directories) + - Reloads the running daemon immediately, without `sudo` or a restart -3. **`roxy start`** - - Starts HTTP (`:80`) and HTTPS (`:443`) servers - - Starts the DNS server (`:53`) for `.roxy` domains - - Routes incoming requests to your local services based on path +3. **First HTTPS request** + - Generates an exact, in-memory certificate from TLS SNI + - Reuses it from an in-memory cache; no per-domain key files are written **Your browser trusts the certificates** (no warnings) because they're signed by your Root CA. WebSockets work transparently. DNS queries for @@ -345,11 +331,12 @@ signed by your Root CA. WebSockets work transparently. DNS queries for **Clean and contained:** -- Config, certs, and CA: `/etc/roxy/` -- Logs: `/var/log/roxy/` -- PID file: `/var/run/roxy.pid` +- macOS data/config: `~/Library/Application Support/Roxy/` +- macOS logs/runtime: `~/Library/Logs/Roxy/` and `~/Library/Caches/Roxy/` +- Linux config/data: `~/.config/roxy/` and `~/.local/share/roxy/` +- Linux logs/runtime: `~/.local/state/roxy/` - DNS: `/etc/resolver/roxy` (macOS) or `/etc/systemd/resolved.conf.d/roxy.conf` (Linux) -- Run `roxy uninstall` to remove everything cleanly +- Run `sudo roxy uninstall` to remove the active installation cleanly For configuration details, logging options, and file locations see the [full documentation](docs/README.md). @@ -384,7 +371,7 @@ No `/etc/hosts`, no config files, no manual cert setup. - **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) +- **sudo** access for the one-time install/uninstall only - **Linux only:** `systemd-resolved` (default on Ubuntu) ### Install via Homebrew @@ -423,7 +410,7 @@ Roxy is ready for daily development use on macOS and Linux. Recent additions and - [x] **Pre-built binaries** — download and run without building from source (macOS ARM64) - [x] **Homebrew support** — `brew tap rbas/roxy && brew install roxy` -- [x] **Auto-start on boot** — launch daemon via launchd with `brew services` +- [x] **Auto-start on boot** — unprivileged launchd/systemd service with socket activation - [x] **File browser** — automatic directory listing for static file routes - [x] **Wildcard subdomains** — `*.myapp.roxy` patterns diff --git a/docs/README.md b/docs/README.md index ce177fb..55bafe6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,15 +12,12 @@ directory, and open `https://myapp.roxy` in your browser. ## Quick Start ```bash -# Initial setup — installs Root CA, configures DNS, -# trusts the certificate in the system trust store +# One-time privileged setup — installs the CA, DNS, +# and a system service that runs Roxy as your user sudo roxy install # Register a domain that proxies to localhost:3000 -sudo roxy register myapp.roxy --route "/=3000" - -# Start the daemon (requires sudo for ports 80/443) -sudo roxy start +roxy register myapp.roxy --route "/=3000" # Open in browser open https://myapp.roxy # macOS @@ -33,24 +30,24 @@ xdg-open https://myapp.roxy # Linux | ---------------------------------- | ---------------------- | | `sudo roxy install` | Initial setup | | `sudo roxy uninstall [--force]` | Full cleanup | -| `sudo roxy register ...` | Register domain | -| `sudo roxy register --wildcard ..` | Register wildcard | -| `sudo roxy unregister ` | Remove domain | +| `roxy register ...` | Register domain | +| `roxy register --wildcard ..` | Register wildcard | +| `roxy unregister ` | Remove domain | | `roxy list` | Show all domains | -| `sudo roxy route add ...` | Add route to domain | +| `roxy route add ...` | Add route to domain | | `roxy route remove ...` | Remove route | | `roxy route list ` | List routes for domain | -| `sudo roxy start [--foreground]` | Start daemon | -| `sudo roxy stop` | Stop daemon | -| `sudo roxy restart` | Restart daemon | -| `sudo roxy reload` | Reload configuration | +| `roxy start` | Start daemon | +| `roxy stop` | Stop daemon | +| `roxy restart` | Restart daemon | +| `roxy reload` | Reload configuration | | `roxy status` | Show daemon status | | `roxy logs [-n N] [-f]` | View or follow logs | | `roxy completions ` | Generate completions | -**Note:** Commands that modify system configuration -(CA certs, DNS) or control the daemon (runs on ports -80/443) require `sudo`. +**Note:** Only `install` and `uninstall` modify system +configuration and require `sudo`. Registration, routing, +logs, reloads, and daemon control run as your user. ## Route Targets @@ -132,12 +129,14 @@ must not leak across hops. ### Debugging Proxy Headers -Enable debug logging to see the forwarding headers -Roxy sets on each request -(see [Logging and Verbosity](#logging-and-verbosity)): +Set `daemon.log_level = "debug"` in the user configuration, +restart, and follow the log to see the forwarding headers Roxy +sets on each request (see +[Logging and Verbosity](#logging-and-verbosity)): ```bash -ROXY_LOG=debug sudo roxy start --foreground +roxy restart +roxy logs -f ``` ```text @@ -150,8 +149,8 @@ DEBUG Proxying HTTP request target=127.0.0.1:3000 Register a domain with `--wildcard` to match the base domain **and** any single-level subdomain. Roxy generates -a wildcard TLS certificate so every subdomain gets -trusted HTTPS automatically. +an exact certificate in memory for each requested hostname, +so every matching subdomain gets trusted HTTPS automatically. ```bash roxy register myapp.roxy --wildcard --route "/=3000" @@ -202,9 +201,8 @@ roxy route list --wildcard myapp.roxy roxy unregister --wildcard myapp.roxy ``` -This removes the wildcard registration and its -certificate. Any exact registration for the same domain -is left untouched. +This removes the wildcard routing registration. Any exact +registration for the same domain is left untouched. ### Configuration @@ -251,21 +249,25 @@ navigate subdirectories ## Files and Directories -```text -/etc/roxy/ -├── config.toml # Main configuration -├── ca.key # Root CA private key -├── ca.crt # Root CA certificate -└── certs/ - ├── .key # Per-domain private key - └── .crt # Per-domain certificate +Roxy keeps mutable state in the developer account that ran +`sudo roxy install`: -/var/run/roxy.pid # PID file (when daemon runs) +```text +macOS +~/Library/Application Support/Roxy/config.toml +~/Library/Application Support/Roxy/ca.{key,crt} +~/Library/Caches/Roxy/{roxy.pid,roxy.sock} +~/Library/Logs/Roxy/roxy.log -/var/log/roxy/ -└── roxy.log # Daemon log file +Linux +~/.config/roxy/config.toml +~/.local/share/roxy/ca.{key,crt} +~/.local/state/roxy/{roxy.log,run/} ``` +Leaf certificates are generated from TLS SNI and cached only +in daemon memory. No per-domain private keys are stored. + DNS configuration (created by `roxy install`): **macOS:** @@ -286,61 +288,41 @@ through the local DNS server. All paths are configurable via the `[paths]` section in `config.toml` (see [Configuration](#configuration)). -## Auto-Start on Boot - -### macOS (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. - -### Linux (systemd) - -Create a systemd service file: +### Upgrading from the root-daemon layout -```bash -sudo tee /etc/systemd/system/roxy.service > /dev/null <<'EOF' -[Unit] -Description=Roxy local development proxy -After=network.target +Run `sudo roxy install` once after upgrading. Roxy imports +registrations and the Root CA from `/etc/roxy`, stops the old +root daemon/service, writes the new user-owned configuration, +and installs socket activation. The legacy `/etc/roxy` +directory is left in place as a migration backup. -[Service] -Type=simple -ExecStart=/usr/local/bin/roxy start --foreground -Restart=on-failure +## Auto-Start and Privileged Ports -[Install] -WantedBy=multi-user.target -EOF +`sudo roxy install` configures this automatically. On macOS, +launchd owns ports 80 and 443. On Linux, systemd socket units +own them. The operating system passes those open listeners to +Roxy, whose daemon process runs as your developer account. -sudo systemctl daemon-reload -sudo systemctl enable --now roxy -``` +This is why routine commands do not need root privileges. +There is no separate `brew services` or hand-written systemd +unit to install. ## Daemon: Foreground vs Background -**Background** (default) — forks to the background, -writes a PID file, logs to `/var/log/roxy/roxy.log`: +**Managed service** (default after installation) — launchd or +systemd runs Roxy as your user and writes to the user log path: ```bash -sudo roxy start +roxy start ``` **Foreground** — stays in the terminal, logs to stdout, -stop with Ctrl+C. Useful for debugging: +and stops with Ctrl+C. This is intended for development before +system installation or with a custom config using unprivileged +ports; the installed socket service already owns ports 80/443: ```bash -sudo roxy start --foreground +roxy --config ./roxy-dev.toml start --foreground ``` ## Logging and Verbosity @@ -354,20 +336,20 @@ roxy logs -f # follow (like tail -f) roxy logs --clear # clear the log file ``` -Change the log level (highest priority first): +Set the daemon log level in your user configuration and restart: -1. **Environment variable** — - `ROXY_LOG=debug sudo roxy start` -2. **CLI flag** — `sudo roxy start --verbose` - (sets debug level) -3. **Config file** — edit `/etc/roxy/config.toml`: +```toml +[daemon] +log_level = "debug" +``` - ```toml - [daemon] - log_level = "debug" - ``` +```bash +roxy restart +``` -4. **Default** — `info` +For an interactive foreground process with custom unprivileged +ports, `ROXY_LOG=debug` overrides the configured level. The +default level is `info`. Available levels: `error`, `warn`, `info`, `debug`. @@ -411,19 +393,21 @@ All commands accept these global flags: | Flag | Default | Description | | ---- | ------- | ----------- | -| `-c`, `--config ` | `/etc/roxy/config.toml` | Config file | +| `-c`, `--config ` | Platform user config | Config file | | `-v`, `--verbose` | off | Enable debug output | Example using a custom config: ```bash -sudo roxy -c /opt/roxy/config.toml start +roxy -c "$HOME/.config/roxy-dev.toml" start ``` ## Configuration -The configuration lives in `/etc/roxy/config.toml` -(override with `--config`). +The default configuration lives at +`~/Library/Application Support/Roxy/config.toml` on macOS +and `~/.config/roxy/config.toml` on Linux. +Override it with `--config`. ### Daemon Section @@ -435,8 +419,9 @@ dns_port = 1053 log_level = "info" ``` -All three ports must be different. The daemon needs -`sudo` to bind to ports below 1024. +All three ports must be different. For an installed service, +launchd or systemd owns the privileged HTTP and HTTPS ports; +the daemon itself remains unprivileged. ### Domain Sections @@ -467,14 +452,15 @@ 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" +data_dir = "/Users/me/Library/Application Support/Roxy" +pid_file = "/Users/me/Library/Caches/Roxy/roxy.pid" +log_file = "/Users/me/Library/Logs/Roxy/roxy.log" +socket_path = "/Users/me/Library/Caches/Roxy/roxy.sock" ``` -The values above are the defaults. You only need this -section if you want different locations. +This macOS example illustrates the available fields. Linux +defaults follow the user paths described above. You only need +this section if you want different locations. ## Docker Integration @@ -510,12 +496,15 @@ 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 +### A Newly Registered Domain Does Not Respond -If accessing `myapp.roxy` shows a certificate for a different domain, the daemon -needs to be restarted to pick up newly registered domains. +Registration automatically reloads the running daemon. If an +external edit or watcher error prevented that reload, request +one explicitly: -**Solution:** Run `sudo roxy restart` after registering new domains. +```bash +roxy reload +``` ### "Connection Refused" or "This site can't be reached" @@ -528,7 +517,7 @@ roxy status If it's not running, start it: ```bash -sudo roxy start +roxy start ``` Verify DNS is working: @@ -557,7 +546,8 @@ sudo lsof -i :1053 ``` Stop the conflicting service or configure Roxy to use -different ports in `/etc/roxy/config.toml`. +different ports in your user configuration file, then rerun +`sudo roxy install` so the socket units use the new ports. ### Backend Service Not Responding diff --git a/docs/docker.md b/docs/docker.md index 7f05aec..fd34945 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -7,7 +7,9 @@ compose stack instantly makes services available at ## Enabling Docker Integration -Add the `[docker]` section to `/etc/roxy/config.toml`: +Add the `[docker]` section to your Roxy user configuration +(`~/Library/Application Support/Roxy/config.toml` on macOS or +`$HOME/.config/roxy/config.toml` on Linux): ```toml [docker] @@ -17,7 +19,7 @@ enabled = true Then restart the daemon: ```bash -sudo roxy restart +roxy restart ``` Roxy connects to the Docker socket and watches for container @@ -235,8 +237,8 @@ they are managed entirely by the Docker watcher. View discovery logs: ```bash -# See container add/remove events -ROXY_LOG=debug sudo roxy start --foreground +# After setting daemon.log_level = "debug" in the Roxy config +roxy restart # Or check the log file roxy logs -f @@ -261,10 +263,12 @@ Check that: 3. The container is not opted out (`roxy.enable=false`) 4. The daemon is running (`roxy status`) -Run with debug logging to see why a container was skipped: +Set `daemon.log_level = "debug"` in the Roxy config, restart, +and follow the log to see why a container was skipped: ```bash -ROXY_LOG=debug sudo roxy start --foreground +roxy restart +roxy logs -f ``` Look for `Docker container skipped` messages with a reason. @@ -297,6 +301,8 @@ labels: ### Docker Socket Permission Roxy connects to the Docker socket -(`/var/run/docker.sock` by default). If running as root -(via `sudo`), this works automatically. If you see connection -errors, verify the socket exists and is accessible. +(`/var/run/docker.sock` by default) as your developer account. +If you see connection errors, verify the socket exists and that +your account can access it. On Linux this commonly means adding +the account to the `docker` group (then logging in again) or +using a rootless Docker socket. diff --git a/docs/linux.md b/docs/linux.md index 008204b..c5ea14b 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -81,7 +81,7 @@ sudo apt install libnss3-tools ```bash certutil -A -n "Roxy Local Development CA" -t "CT,C,C" \ - -i /etc/roxy/ca.crt \ + -i "$HOME/.local/share/roxy/ca.crt" \ -d sql:$(find ~/snap/firefox/common/.mozilla/firefox \ -name '*.default*' -type d | head -1)/ ``` @@ -90,7 +90,7 @@ certutil -A -n "Roxy Local Development CA" -t "CT,C,C" \ ```bash certutil -A -n "Roxy Local Development CA" -t "CT,C,C" \ - -i /etc/roxy/ca.crt \ + -i "$HOME/.local/share/roxy/ca.crt" \ -d sql:$(find ~/snap/chromium -name 'nssdb' \ -type d | head -1)/ ``` @@ -175,33 +175,36 @@ 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`. +Roxy's ports in +`$HOME/.config/roxy/config.toml`, then +rerun `sudo roxy install` to update the socket units. -### Auto-Start with systemd +### Service and Socket Activation -Create a service file to start Roxy at boot: +`sudo roxy install` creates and enables these system units: + +```text +roxy-http.socket +roxy-https.socket +roxy.service +``` + +The socket units own ports 80 and 443 and pass their file +descriptors to `roxy.service`. The service has `User=` set to +the developer who ran the installer, so configuration, logs, +Docker access, and the daemon process do not use root. + +Use Roxy for normal lifecycle management: ```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 +roxy status +roxy stop +roxy start ``` -Check status: +For system-level diagnostics, inspect the generated units: ```bash sudo systemctl status roxy +sudo systemctl status roxy-http.socket roxy-https.socket ``` diff --git a/scripts/formula.rb.template b/scripts/formula.rb.template index 1c8b49e..133435e 100644 --- a/scripts/formula.rb.template +++ b/scripts/formula.rb.template @@ -26,22 +26,11 @@ class Roxy < Formula bin.install "roxy" end - service do - name macos: "cz.rbas.roxy", linux: "roxy" - 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" - end - def caveats <<~EOS - Roxy requires a one-time setup before use: + Roxy requires one privileged setup. This installs its socket-activated + service, which runs as your developer account: sudo roxy install - - To start roxy now and auto-start at boot: - sudo brew services start roxy EOS end diff --git a/scripts/test-brew.sh b/scripts/test-brew.sh index efe023c..8826dc1 100755 --- a/scripts/test-brew.sh +++ b/scripts/test-brew.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Test Homebrew formula locally with brew services. +# Test the Homebrew formula and Roxy's managed service locally. # # Usage: # ./scripts/test-brew.sh # build + install + show next steps @@ -16,7 +16,7 @@ VERSION=$(grep '^version' "$REPO_ROOT/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/ # ── Clean mode ───────────────────────────────────────────── if [[ "${1:-}" == "clean" ]]; then echo "==> Cleaning up test install..." - sudo brew services stop roxy 2>/dev/null || true + sudo roxy uninstall --force 2>/dev/null || true brew uninstall roxy 2>/dev/null || true # Restore tap from remote @@ -27,7 +27,6 @@ if [[ "${1:-}" == "clean" ]]; then fi # Clean up roxy state - sudo roxy uninstall --force 2>/dev/null || true rm -f "$TARBALL" echo "Done. Tap restored, roxy uninstalled." @@ -79,22 +78,16 @@ 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 " roxy register test.roxy --route '/=8080'" echo "" -echo " # 3b. Test brew services (auto-start at boot)" -echo " sudo brew services start roxy" -echo " sudo brew services info roxy" +echo " # 3. Test the automatically installed user service" echo " roxy status" -echo " sudo brew services stop roxy" +echo " roxy stop" +echo " roxy start" echo "" echo " # 4. Verify config location" -echo " cat /etc/roxy/config.toml" -echo " ls -la /etc/roxy/" +echo " cat \"$HOME/Library/Application Support/Roxy/config.toml\"" +echo " ls -la \"$HOME/Library/Application Support/Roxy/\"" echo "" echo " # 5. Clean up when done" echo " ./scripts/test-brew.sh clean" diff --git a/src/application/list_all_domains.rs b/src/application/list_all_domains.rs index 2534f84..547143c 100644 --- a/src/application/list_all_domains.rs +++ b/src/application/list_all_domains.rs @@ -46,19 +46,17 @@ impl<'a> ListAllDomains<'a> { /// (config + Docker), falling back to config-only if daemon is not running. pub fn execute(&self) -> Result { let cert_trusted = self.certs.is_trusted().ok(); + let https_available = cert_trusted.unwrap_or(false); // Try daemon first — it has config + Docker domains match self.daemon.list_registrations() { Ok(regs) => { let domains = regs .into_iter() - .map(|reg| { - let has_cert = self.certs.exists(reg.pattern()); - DomainInfo { - registration: reg, - has_cert, - cert_trusted, - } + .map(|reg| DomainInfo { + registration: reg, + has_cert: https_available, + cert_trusted, }) .collect(); @@ -79,13 +77,10 @@ impl<'a> ListAllDomains<'a> { let regs = self.domains.list()?; let domains = regs .into_iter() - .map(|reg| { - let has_cert = self.certs.exists(reg.pattern()); - DomainInfo { - registration: reg, - has_cert, - cert_trusted, - } + .map(|reg| DomainInfo { + registration: reg, + has_cert: https_available, + cert_trusted, }) .collect(); @@ -149,7 +144,6 @@ mod tests { let daemon = InMemoryDaemonConnection::new(vec![registration("app.roxy")]); let repo = InMemoryDomainRepository::new(); let certs = InMemoryCertificateManager::with_ca_installed(); - certs.create_and_install(&exact("app.roxy")).unwrap(); let svc = ListAllDomains::new(&daemon, &repo, &certs); let result = svc.execute().unwrap(); diff --git a/src/application/ports/certificate_manager.rs b/src/application/ports/certificate_manager.rs index d3cb33e..5ee37b6 100644 --- a/src/application/ports/certificate_manager.rs +++ b/src/application/ports/certificate_manager.rs @@ -1,5 +1,3 @@ -use crate::domain::DomainPattern; - #[derive(Debug, thiserror::Error)] pub enum CertificateError { #[error("{0}")] @@ -14,18 +12,9 @@ pub trait CertificateManager { /// Check if the Root CA exists and is trusted. fn is_ca_installed(&self) -> Result; - /// Generate and install a certificate for the given domain pattern. - fn create_and_install(&self, pattern: &DomainPattern) -> Result<(), CertificateError>; - - /// Remove certificate files for a domain pattern. - fn remove(&self, pattern: &DomainPattern) -> Result<(), CertificateError>; - /// Remove the Root CA from the trust store and delete CA files. fn remove_ca(&self) -> Result<(), CertificateError>; - /// Check if a certificate exists for a domain pattern. - fn exists(&self, pattern: &DomainPattern) -> bool; - /// Check if the CA certificate is trusted by the system. fn is_trusted(&self) -> Result; } diff --git a/src/application/ports/system_setup.rs b/src/application/ports/system_setup.rs index bf63b89..9656f19 100644 --- a/src/application/ports/system_setup.rs +++ b/src/application/ports/system_setup.rs @@ -1,6 +1,6 @@ /// Port for system-level directory and file operations. pub trait SystemSetup { - /// Create required data, certs, and log directories. + /// Create required data and log directories. fn create_directories(&self) -> anyhow::Result<()>; /// Remove the data directory. Returns true if it existed. diff --git a/src/application/register_domain.rs b/src/application/register_domain.rs index de91ea9..c30660e 100644 --- a/src/application/register_domain.rs +++ b/src/application/register_domain.rs @@ -2,27 +2,24 @@ use anyhow::{Result, bail}; use crate::domain::{DomainPattern, DomainRegistration, Route}; -use super::StepOutcome; -use super::ports::{CertificateManager, DomainRepository}; +use super::ports::DomainRepository; /// Result of a successful domain registration. pub struct RegisterResult { pub registration: DomainRegistration, - pub cert_outcome: StepOutcome, } /// Use case: register a new domain with routes. pub struct RegisterDomain<'a> { domains: &'a dyn DomainRepository, - certs: &'a dyn CertificateManager, } impl<'a> RegisterDomain<'a> { - pub fn new(domains: &'a dyn DomainRepository, certs: &'a dyn CertificateManager) -> Self { - Self { domains, certs } + pub fn new(domains: &'a dyn DomainRepository) -> Self { + Self { domains } } - /// Validate inputs, generate a certificate, and persist the registration. + /// Validate inputs and persist the registration. pub fn execute(&self, pattern: DomainPattern, routes: Vec) -> Result { if routes.is_empty() { bail!( @@ -47,27 +44,14 @@ impl<'a> RegisterDomain<'a> { ); } - let mut registration = DomainRegistration::new(pattern.clone(), routes); - - // Generate certificate (graceful fallback) - let cert_outcome = match self.certs.create_and_install(&pattern) { - Ok(()) => { - registration.enable_https(); - StepOutcome::Success("Certificate installed and trusted.".into()) - } - Err(e) => StepOutcome::Warning(format!( - "Failed to generate certificate: {}. \ - HTTPS will not be available for this domain.", - e - )), - }; + let mut registration = DomainRegistration::new(pattern, routes); + // HTTPS is provided by the daemon's CA-backed SNI resolver. Registering + // a route never needs to create or persist a leaf certificate. + registration.enable_https(); self.domains.add(registration.clone())?; - Ok(RegisterResult { - registration, - cert_outcome, - }) + Ok(RegisterResult { registration }) } } @@ -79,39 +63,20 @@ mod tests { #[test] fn registers_domain_with_https() { let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = RegisterDomain::new(&repo, &certs); + let svc = RegisterDomain::new(&repo); let result = svc .execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) .unwrap(); assert!(result.registration.is_https_enabled()); - assert!(matches!(result.cert_outcome, StepOutcome::Success(_))); - assert!(repo.get(&exact("myapp.roxy")).unwrap().is_some()); - } - - #[test] - fn registers_domain_without_https_when_cert_fails() { - let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::always_failing(); - let svc = RegisterDomain::new(&repo, &certs); - - let result = svc - .execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) - .unwrap(); - - assert!(!result.registration.is_https_enabled()); - assert!(matches!(result.cert_outcome, StepOutcome::Warning(_))); - // Domain is still registered despite cert failure assert!(repo.get(&exact("myapp.roxy")).unwrap().is_some()); } #[test] fn rejects_empty_routes() { let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = RegisterDomain::new(&repo, &certs); + let svc = RegisterDomain::new(&repo); let err = svc.execute(exact("myapp.roxy"), vec![]).err().unwrap(); assert!(err.to_string().contains("At least one route")); @@ -120,8 +85,7 @@ mod tests { #[test] fn rejects_duplicate_domain() { let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = RegisterDomain::new(&repo, &certs); + let svc = RegisterDomain::new(&repo); svc.execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) .unwrap(); @@ -136,8 +100,7 @@ mod tests { #[test] fn multiple_routes_are_persisted() { let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = RegisterDomain::new(&repo, &certs); + let svc = RegisterDomain::new(&repo); let routes = vec![proxy_route("/", 3000), proxy_route("/api", 3001)]; let result = svc.execute(exact("myapp.roxy"), routes).unwrap(); diff --git a/src/application/restart_daemon.rs b/src/application/restart_daemon.rs index 7162df5..f60b3c5 100644 --- a/src/application/restart_daemon.rs +++ b/src/application/restart_daemon.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use anyhow::{Result, bail}; +use anyhow::Result; use crate::config::{DaemonConfig, RoxyPaths}; @@ -14,10 +14,8 @@ pub struct RestartReady { /// Application service for restarting/reloading the daemon. /// -/// Both `restart` and `reload` CLI commands use this. -/// The `require_running` parameter distinguishes them: -/// - `reload` requires the daemon to be running -/// - `restart` tolerates a stopped daemon +/// Restart stops the current process and returns freshly loaded startup data. +/// Configuration-only reloads use the daemon management socket instead. pub struct RestartDaemon<'a> { daemon: &'a dyn DaemonControl, config_loader: &'a dyn ConfigLoader, @@ -33,23 +31,14 @@ impl<'a> RestartDaemon<'a> { /// Restart the daemon. Tolerates a stopped daemon (just re-reads config). pub fn restart(&self) -> Result { - self.stop_and_reload(false) + self.stop_and_reload() } - /// Reload the daemon. Fails if the daemon is not running. - pub fn reload(&self) -> Result { - self.stop_and_reload(true) - } - - fn stop_and_reload(&self, require_running: bool) -> Result { + fn stop_and_reload(&self) -> Result { let is_running = self.daemon.is_running()?; - if require_running && !is_running { - bail!("Roxy daemon is not running.\nStart it with: sudo roxy start"); - } - if is_running { - self.daemon.stop_gracefully(Duration::from_millis(500))?; + self.daemon.stop_gracefully(Duration::from_secs(2))?; } // Re-load config from disk to pick up changes @@ -90,26 +79,4 @@ mod tests { let ready = svc.restart().unwrap(); assert_eq!(ready.daemon_config.http_port, 80); } - - #[test] - fn reload_fails_when_daemon_not_running() { - let daemon = InMemoryDaemonControl::stopped(); - let loader = InMemoryConfigLoader::existing(); - let svc = RestartDaemon::new(&daemon, &loader); - - let err = svc.reload().err().unwrap(); - assert!(err.to_string().contains("not running")); - } - - #[test] - fn reload_succeeds_when_daemon_running() { - let daemon = InMemoryDaemonControl::running(5678); - let loader = InMemoryConfigLoader::existing(); - let svc = RestartDaemon::new(&daemon, &loader); - - let ready = svc.reload().unwrap(); - - assert!(!daemon.is_running().unwrap()); - assert_eq!(ready.daemon_config.dns_port, 1053); - } } diff --git a/src/application/stop_daemon.rs b/src/application/stop_daemon.rs index 34fab0e..2118681 100644 --- a/src/application/stop_daemon.rs +++ b/src/application/stop_daemon.rs @@ -20,7 +20,7 @@ impl<'a> StopDaemon<'a> { bail!("Roxy daemon is not running."); } - self.daemon.stop_gracefully(Duration::from_millis(500))?; + self.daemon.stop_gracefully(Duration::from_secs(2))?; Ok(()) } } diff --git a/src/application/testkit.rs b/src/application/testkit.rs index 6038c9e..58c1fea 100644 --- a/src/application/testkit.rs +++ b/src/application/testkit.rs @@ -15,37 +15,14 @@ use crate::domain::{ use super::ports::{ CertificateError, CertificateManager, ConfigLoadError, ConfigLoader, DaemonConnection, DaemonConnectionError, DaemonControl, DaemonRuntimeInfo, DnsConfigError, DnsManager, - DomainRepository, NetworkInfo, RegistrationProvider, RepositoryError, SystemSetup, + DomainRepository, NetworkInfo, RepositoryError, SystemSetup, }; -// --------------------------------------------------------------------------- -// InMemoryRegistrationProvider -// --------------------------------------------------------------------------- - -pub struct InMemoryRegistrationProvider { - registrations: Vec, -} - -impl InMemoryRegistrationProvider { - pub fn new(registrations: Vec) -> Self { - Self { registrations } - } -} - -impl RegistrationProvider for InMemoryRegistrationProvider { - fn name(&self) -> &str { - "in-memory" - } - - fn load(&self) -> anyhow::Result> { - Ok(self.registrations.clone()) - } -} - // --------------------------------------------------------------------------- // InMemoryDomainRepository // --------------------------------------------------------------------------- +#[derive(Default)] pub struct InMemoryDomainRepository { domains: RefCell>, } @@ -119,10 +96,10 @@ impl DomainRepository for InMemoryDomainRepository { // InMemoryCertificateManager // --------------------------------------------------------------------------- +#[derive(Default)] pub struct InMemoryCertificateManager { ca_installed: RefCell, - certs: RefCell>, - /// When true, all cert operations fail. + /// When true, all CA operations fail. fail_operations: bool, } @@ -130,7 +107,6 @@ impl InMemoryCertificateManager { pub fn new() -> Self { Self { ca_installed: RefCell::new(false), - certs: RefCell::new(Vec::new()), fail_operations: false, } } @@ -138,7 +114,6 @@ impl InMemoryCertificateManager { pub fn always_failing() -> Self { Self { ca_installed: RefCell::new(false), - certs: RefCell::new(Vec::new()), fail_operations: true, } } @@ -146,7 +121,6 @@ impl InMemoryCertificateManager { pub fn with_ca_installed() -> Self { Self { ca_installed: RefCell::new(true), - certs: RefCell::new(Vec::new()), fail_operations: false, } } @@ -167,28 +141,6 @@ impl CertificateManager for InMemoryCertificateManager { Ok(*self.ca_installed.borrow()) } - fn create_and_install(&self, pattern: &DomainPattern) -> Result<(), CertificateError> { - if self.fail_operations { - return Err(CertificateError::OperationFailed(anyhow::anyhow!( - "simulated cert failure" - ))); - } - self.certs.borrow_mut().push(pattern.display_pattern()); - Ok(()) - } - - fn remove(&self, pattern: &DomainPattern) -> Result<(), CertificateError> { - if self.fail_operations { - return Err(CertificateError::OperationFailed(anyhow::anyhow!( - "simulated remove failure" - ))); - } - self.certs - .borrow_mut() - .retain(|c| *c != pattern.display_pattern()); - Ok(()) - } - fn remove_ca(&self) -> Result<(), CertificateError> { if self.fail_operations { return Err(CertificateError::OperationFailed(anyhow::anyhow!( @@ -199,10 +151,6 @@ impl CertificateManager for InMemoryCertificateManager { Ok(()) } - fn exists(&self, pattern: &DomainPattern) -> bool { - self.certs.borrow().contains(&pattern.display_pattern()) - } - fn is_trusted(&self) -> Result { Ok(*self.ca_installed.borrow()) } @@ -212,6 +160,7 @@ impl CertificateManager for InMemoryCertificateManager { // InMemoryConfigLoader // --------------------------------------------------------------------------- +#[derive(Default)] pub struct InMemoryConfigLoader { file_exists: RefCell, daemon_config: DaemonConfig, @@ -292,6 +241,7 @@ impl DaemonControl for InMemoryDaemonControl { // InMemoryDnsManager // --------------------------------------------------------------------------- +#[derive(Default)] pub struct InMemoryDnsManager { configured: RefCell, } @@ -358,6 +308,7 @@ impl NetworkInfo for InMemoryNetworkInfo { // InMemorySystemSetup // --------------------------------------------------------------------------- +#[derive(Default)] pub struct InMemorySystemSetup { directories_created: RefCell, data_exists: RefCell, diff --git a/src/application/uninstall.rs b/src/application/uninstall.rs index f538f09..42ad617 100644 --- a/src/application/uninstall.rs +++ b/src/application/uninstall.rs @@ -61,7 +61,7 @@ impl<'a> Uninstall<'a> { }) } - /// Perform the full uninstall: stop daemon, remove certs, DNS, + /// Perform the full uninstall: stop daemon, remove the Root CA, DNS, /// data directory, PID file, and logs. pub fn execute(&self) -> Result { let mut steps: Vec<(String, StepOutcome)> = Vec::new(); @@ -77,7 +77,7 @@ impl<'a> Uninstall<'a> { fn stop_daemon(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { if self.daemon.get_running_pid()?.is_some() { - self.daemon.stop_gracefully(Duration::from_millis(500))?; + self.daemon.stop_gracefully(Duration::from_secs(2))?; steps.push(( "Stop daemon".into(), StepOutcome::Success("Daemon stopped.".into()), @@ -92,23 +92,6 @@ impl<'a> Uninstall<'a> { } fn remove_certificates(&self, steps: &mut Vec<(String, StepOutcome)>) { - let domains = match self.domains.list() { - Ok(domains) => domains, - Err(e) => { - warn!(error = %e, "Could not read domain list for certificate cleanup"); - Vec::new() - } - }; - - for registration in &domains { - let label = format!("Remove cert: {}", registration.display_pattern()); - let outcome = match self.certs.remove(registration.pattern()) { - Ok(_) => StepOutcome::Success("Removed.".into()), - Err(e) => StepOutcome::Warning(format!("Failed: {}", e)), - }; - steps.push((label, outcome)); - } - let ca_outcome = match self.certs.remove_ca() { Ok(_) => StepOutcome::Success("Root CA removed.".into()), Err(e) => StepOutcome::Warning(format!("Failed to remove Root CA: {}", e)), @@ -225,7 +208,7 @@ mod tests { } #[test] - fn uninstall_warns_on_cert_removal_failure() { + fn uninstall_warns_on_ca_removal_failure() { let repo = InMemoryDomainRepository::with_domains(vec![registration("myapp.roxy")]); let certs = InMemoryCertificateManager::always_failing(); let daemon = InMemoryDaemonControl::stopped(); @@ -236,14 +219,11 @@ mod tests { let result = svc.execute().unwrap(); // Cert removal should warn, not fail the whole operation - let cert_steps: Vec<_> = result + let ca_step = result .steps .iter() - .filter(|(label, _)| label.starts_with("Remove cert")) - .collect(); - assert!(!cert_steps.is_empty()); - for (_, outcome) in cert_steps { - assert!(matches!(outcome, StepOutcome::Warning(_))); - } + .find(|(label, _)| label == "Remove Root CA") + .unwrap(); + assert!(matches!(ca_step.1, StepOutcome::Warning(_))); } } diff --git a/src/application/unregister_domain.rs b/src/application/unregister_domain.rs index 5e507fe..c78b4a5 100644 --- a/src/application/unregister_domain.rs +++ b/src/application/unregister_domain.rs @@ -2,24 +2,21 @@ use anyhow::{Result, anyhow}; use crate::domain::{DomainPattern, DomainRegistration}; -use super::StepOutcome; -use super::ports::{CertificateManager, DomainRepository}; +use super::ports::DomainRepository; /// Result of a successful domain unregistration. pub struct UnregisterResult { pub registration: DomainRegistration, - pub cert_outcome: StepOutcome, } /// Use case: unregister a domain and clean up its certificate. pub struct UnregisterDomain<'a> { domains: &'a dyn DomainRepository, - certs: &'a dyn CertificateManager, } impl<'a> UnregisterDomain<'a> { - pub fn new(domains: &'a dyn DomainRepository, certs: &'a dyn CertificateManager) -> Self { - Self { domains, certs } + pub fn new(domains: &'a dyn DomainRepository) -> Self { + Self { domains } } /// Look up the registration so the CLI can show a confirmation @@ -30,25 +27,13 @@ impl<'a> UnregisterDomain<'a> { .ok_or_else(|| anyhow!("Domain '{}' is not registered.", pattern)) } - /// Remove the domain certificate and config entry. + /// Remove the domain config entry. pub fn execute(&self, pattern: &DomainPattern) -> Result { let registration = self.preview(pattern)?; - let cert_outcome = if self.certs.exists(pattern) { - match self.certs.remove(pattern) { - Ok(()) => StepOutcome::Success("Certificate removed.".into()), - Err(e) => StepOutcome::Warning(format!("Failed to remove certificate: {}", e)), - } - } else { - StepOutcome::Skipped("No certificate to remove.".into()) - }; - self.domains.remove(pattern)?; - Ok(UnregisterResult { - registration, - cert_outcome, - }) + Ok(UnregisterResult { registration }) } } @@ -58,37 +43,19 @@ mod tests { use crate::application::testkit::*; #[test] - fn unregisters_domain_and_removes_cert() { - let repo = InMemoryDomainRepository::with_domains(vec![registration("myapp.roxy")]); - let certs = InMemoryCertificateManager::new(); - // Install cert first so it exists - certs.create_and_install(&exact("myapp.roxy")).unwrap(); - let svc = UnregisterDomain::new(&repo, &certs); - - let result = svc.execute(&exact("myapp.roxy")).unwrap(); - - assert!(matches!(result.cert_outcome, StepOutcome::Success(_))); - assert!(repo.get(&exact("myapp.roxy")).unwrap().is_none()); - assert!(!certs.exists(&exact("myapp.roxy"))); - } - - #[test] - fn unregisters_domain_without_cert() { + fn unregisters_domain() { let repo = InMemoryDomainRepository::with_domains(vec![registration("myapp.roxy")]); - let certs = InMemoryCertificateManager::new(); - let svc = UnregisterDomain::new(&repo, &certs); + let svc = UnregisterDomain::new(&repo); - let result = svc.execute(&exact("myapp.roxy")).unwrap(); + svc.execute(&exact("myapp.roxy")).unwrap(); - assert!(matches!(result.cert_outcome, StepOutcome::Skipped(_))); assert!(repo.get(&exact("myapp.roxy")).unwrap().is_none()); } #[test] fn fails_for_unknown_domain() { let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = UnregisterDomain::new(&repo, &certs); + let svc = UnregisterDomain::new(&repo); let err = svc.execute(&exact("unknown.roxy")).err().unwrap(); assert!(err.to_string().contains("not registered")); @@ -97,8 +64,7 @@ mod tests { #[test] fn preview_returns_registration() { let repo = InMemoryDomainRepository::with_domains(vec![registration("myapp.roxy")]); - let certs = InMemoryCertificateManager::new(); - let svc = UnregisterDomain::new(&repo, &certs); + let svc = UnregisterDomain::new(&repo); let reg = svc.preview(&exact("myapp.roxy")).unwrap(); assert_eq!(reg.domain().as_str(), "myapp.roxy"); diff --git a/src/cli/context.rs b/src/cli/context.rs index f7a6209..3ff4159 100644 --- a/src/cli/context.rs +++ b/src/cli/context.rs @@ -1,5 +1,6 @@ use std::path::Path; +use crate::application::ports::{DaemonConnection, DaemonConnectionError}; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::ConfigStore; use crate::infrastructure::mgmt_client::MgmtSocketClient; @@ -24,4 +25,12 @@ impl AppContext { mgmt_client: MgmtSocketClient::new(&paths.socket_path), } } + + /// Ask a running daemon to reload, while allowing offline configuration. + pub fn reload_if_running(&self) -> anyhow::Result<()> { + match self.mgmt_client.reload() { + Ok(()) | Err(DaemonConnectionError::NotRunning) => Ok(()), + Err(error) => Err(error.into()), + } + } } diff --git a/src/cli/install.rs b/src/cli/install.rs index 795da3e..e098833 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -8,10 +8,24 @@ use crate::infrastructure::dns::get_dns_service; use crate::infrastructure::filesystem::FileSystemSetup; use crate::infrastructure::network::get_network_info; use crate::infrastructure::paths::RoxyPaths; +use crate::infrastructure::pid::PidFile; +use crate::infrastructure::service; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; -pub fn execute(ctx: &AppContext, paths: &RoxyPaths, config: &Config) -> Result<()> { +pub fn execute( + ctx: &AppContext, + config_path: &Path, + paths: &RoxyPaths, + config: &Config, +) -> Result<()> { + service::validate_install_invocation(config_path, paths)?; println!("Setting up Roxy...\n"); + migrate_legacy_ca(paths)?; + let dns_service = get_dns_service()?; let network_info = get_network_info(); let system = FileSystemSetup::new(paths); @@ -25,6 +39,8 @@ pub fn execute(ctx: &AppContext, paths: &RoxyPaths, config: &Config) -> Result<( config.daemon.dns_port, ); let result = use_case.execute()?; + let stopped_legacy_daemon = stop_legacy_daemon()?; + let runtime_user = service::install(config_path, paths, &config.daemon)?; println!(" Using IP address: {}", result.lan_ip); if result.lan_ip.is_loopback() { @@ -38,10 +54,58 @@ pub fn execute(ctx: &AppContext, paths: &RoxyPaths, config: &Config) -> Result<( StepOutcome::Skipped(msg) => println!(" {} {}", label, msg), } } + if stopped_legacy_daemon { + println!(" Migration: previous root daemon stopped and replaced."); + } println!("\nRoxy installation complete!"); + println!(" Daemon user: {}", runtime_user.name); println!(); - println!("Register domains with: roxy register --port "); + println!("Register domains without sudo: roxy register --route \"/=3000\""); Ok(()) } + +fn stop_legacy_daemon() -> Result { + let pid_file = PidFile::new(PathBuf::from("/var/run/roxy.pid")); + let Some(pid) = pid_file.get_running_pid()? else { + return Ok(false); + }; + + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output()?; + let executable = String::from_utf8_lossy(&output.stdout); + let is_roxy = Path::new(executable.trim()) + .file_name() + .is_some_and(|name| name == "roxy"); + if !output.status.success() || !is_roxy { + anyhow::bail!( + "Legacy PID file points to a non-Roxy process ({pid}); remove \ + /var/run/roxy.pid after verifying that process" + ); + } + + pid_file.stop_gracefully(Duration::from_secs(2))?; + Ok(true) +} + +fn migrate_legacy_ca(paths: &RoxyPaths) -> Result<()> { + let legacy_dir = Path::new("/etc/roxy"); + let pairs = [ + (legacy_dir.join("ca.crt"), paths.data_dir.join("ca.crt")), + (legacy_dir.join("ca.key"), paths.data_dir.join("ca.key")), + ]; + if pairs.iter().all(|(_, destination)| destination.exists()) + || !pairs.iter().all(|(source, _)| source.exists()) + { + return Ok(()); + } + + fs::create_dir_all(&paths.data_dir)?; + for (source, destination) in pairs { + fs::copy(source, destination)?; + } + crate::infrastructure::file_security::restrict_key_permissions(&paths.data_dir.join("ca.key"))?; + Ok(()) +} diff --git a/src/cli/logs.rs b/src/cli/logs.rs index aa957dd..d51bf7d 100644 --- a/src/cli/logs.rs +++ b/src/cli/logs.rs @@ -21,7 +21,7 @@ pub fn execute(lines: usize, clear: bool, follow: bool, paths: &RoxyPaths) -> Re if !log_path.exists() { println!("No logs found."); println!("Log file: {}", log_path.display()); - println!("\nStart the daemon to generate logs: sudo roxy start"); + println!("\nStart the daemon to generate logs: roxy start"); return Ok(()); } diff --git a/src/cli/register.rs b/src/cli/register.rs index d2a283a..fd8cca7 100644 --- a/src/cli/register.rs +++ b/src/cli/register.rs @@ -1,7 +1,6 @@ use anyhow::Result; use super::context::AppContext; -use crate::application::StepOutcome; use crate::application::register_domain::RegisterDomain; use crate::domain::{DomainPattern, Route}; @@ -19,27 +18,10 @@ pub fn execute( .collect::, _>>() .map_err(|e| anyhow::anyhow!("Invalid route: {}", e))?; - let use_case = RegisterDomain::new(&ctx.config_store, &ctx.cert_service); - - println!( - "Generating SSL certificate for {}...", - pattern.display_pattern() - ); + let use_case = RegisterDomain::new(&ctx.config_store); let result = use_case.execute(pattern, parsed_routes)?; - - match &result.cert_outcome { - StepOutcome::Success(msg) => println!(" {}", msg), - StepOutcome::Warning(msg) => { - eprintln!(" {}", msg); - eprintln!( - " Run 'sudo roxy register {}{}' to enable HTTPS.", - result.registration.domain(), - if wildcard { " --wildcard" } else { "" } - ); - } - StepOutcome::Skipped(msg) => println!(" {}", msg), - } + ctx.reload_if_running()?; println!( "\nRegistered domain: {}", @@ -57,7 +39,7 @@ pub fn execute( "disabled" } ); - println!("\nStart the proxy with: roxy start"); + println!("\nThe running proxy has been updated."); Ok(()) } diff --git a/src/cli/reload.rs b/src/cli/reload.rs index 9bf7868..2ea8ff7 100644 --- a/src/cli/reload.rs +++ b/src/cli/reload.rs @@ -1,23 +1,10 @@ -use std::path::Path; - use anyhow::Result; use super::context::AppContext; -use crate::application::restart_daemon::RestartDaemon; - -pub fn execute(verbose: bool, config_path: &Path, ctx: &AppContext) -> Result<()> { - let service = RestartDaemon::new(&ctx.pid_file, &ctx.config_store); - let ready = service.reload()?; - - println!("Starting Roxy daemon..."); - super::start::execute( - false, - verbose, - config_path, - &ready.paths, - &ready.daemon_config, - )?; +use crate::application::ports::DaemonConnection; +pub fn execute(ctx: &AppContext) -> Result<()> { + ctx.mgmt_client.reload()?; println!("Daemon reloaded with updated configuration."); Ok(()) } diff --git a/src/cli/route.rs b/src/cli/route.rs index f5cf13b..520976d 100644 --- a/src/cli/route.rs +++ b/src/cli/route.rs @@ -20,9 +20,9 @@ pub fn add( let use_case = ManageRoutes::new(&ctx.config_store); let route = use_case.add_route(&pattern, path_prefix, route_target)?; + ctx.reload_if_running()?; println!("Added route: {} -> {}", route.path(), route.target()); - println!("\nReload the daemon to apply changes: roxy reload"); Ok(()) } @@ -35,9 +35,9 @@ pub fn remove(domain: String, wildcard: bool, path: String, ctx: &AppContext) -> let use_case = ManageRoutes::new(&ctx.config_store); use_case.remove_route(&pattern, &path_prefix)?; + ctx.reload_if_running()?; println!("Removed route: {}", path_prefix); - println!("\nReload the daemon to apply changes: roxy reload"); Ok(()) } diff --git a/src/cli/start.rs b/src/cli/start.rs index 6c811e9..a452195 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -9,6 +9,7 @@ use crate::config::DaemonConfig; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; +use crate::infrastructure::service; pub fn execute( foreground: bool, @@ -26,6 +27,23 @@ pub fn execute( return crate::daemon::lifecycle::run(verbose, config_path, paths); } + if service::is_installed() { + service::activate(ready.http_port)?; + for _ in 0..20 { + if let Some(pid) = pid_file.get_running_pid()? { + println!("Roxy daemon started (PID: {pid})"); + println!( + "Listening on 0.0.0.0:{} (HTTP) and 0.0.0.0:{} (HTTPS)", + ready.http_port, ready.https_port + ); + println!("Use 'roxy stop' to stop the daemon"); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + anyhow::bail!("Roxy service was activated but did not become ready"); + } + // Fork to background let exe = env::current_exe()?; diff --git a/src/cli/status.rs b/src/cli/status.rs index 3b8f5c6..187ee89 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -43,7 +43,7 @@ pub fn execute(ctx: &AppContext, daemon_config: &DaemonConfig) -> Result<()> { println!("Roxy daemon: stopped"); println!(" LAN IP: {}{}", status.lan_ip, offline_note); println!(" Root CA: {}", ca_label); - println!("\nStart with: sudo roxy start"); + println!("\nStart with: roxy start"); } } diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index a2f8865..1b7856a 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -6,6 +6,7 @@ use crate::application::uninstall::Uninstall; use crate::infrastructure::dns::get_dns_service; use crate::infrastructure::filesystem::FileSystemSetup; use crate::infrastructure::paths::RoxyPaths; +use crate::infrastructure::service; pub fn execute(force: bool, ctx: &AppContext, paths: &RoxyPaths) -> Result<()> { let dns_service = get_dns_service()?; @@ -34,6 +35,7 @@ pub fn execute(force: bool, ctx: &AppContext, paths: &RoxyPaths) -> Result<()> { println!("Uninstalling Roxy...\n"); + service::uninstall()?; let result = use_case.execute()?; for (label, outcome) in &result.steps { diff --git a/src/cli/unregister.rs b/src/cli/unregister.rs index 280ad8e..ffb8503 100644 --- a/src/cli/unregister.rs +++ b/src/cli/unregister.rs @@ -1,14 +1,13 @@ use anyhow::Result; use super::context::AppContext; -use crate::application::StepOutcome; use crate::application::unregister_domain::UnregisterDomain; use crate::domain::DomainPattern; pub fn execute(domain: String, wildcard: bool, force: bool, ctx: &AppContext) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; - let use_case = UnregisterDomain::new(&ctx.config_store, &ctx.cert_service); + let use_case = UnregisterDomain::new(&ctx.config_store); if !force { let registration = use_case.preview(&pattern)?; @@ -18,20 +17,12 @@ pub fn execute(domain: String, wildcard: bool, force: bool, ctx: &AppContext) -> for route in registration.routes() { println!(" {} -> {}", route.path(), route.target()); } - if registration.is_https_enabled() { - println!(" HTTPS certificate files will be removed"); - } println!("\nRun with --force to confirm."); return Ok(()); } let result = use_case.execute(&pattern)?; - - match &result.cert_outcome { - StepOutcome::Success(msg) => println!("{}", msg), - StepOutcome::Warning(msg) => eprintln!("{}", msg), - StepOutcome::Skipped(_) => {} - } + ctx.reload_if_running()?; println!( "Unregistered domain: {}", diff --git a/src/config.rs b/src/config.rs index 6230811..e932a75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use std::env; use std::path::PathBuf; +use std::process::Command; fn default_http_port() -> u16 { 80 @@ -77,24 +79,124 @@ impl DaemonConfig { } } +/// Home directory of the account that owns the Roxy runtime. +/// +/// `sudo` normally changes `HOME`, so installation resolves the original +/// account from `SUDO_USER`. Everyday commands simply use `HOME`. +pub(crate) fn runtime_home_dir() -> PathBuf { + if let Some(user) = env::var_os("SUDO_USER").filter(|value| value != "root") + && let Some(home) = lookup_home_dir(&user.to_string_lossy()) + { + return home; + } + + env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(target_os = "macos")] +fn lookup_home_dir(user: &str) -> Option { + let output = Command::new("dscl") + .args([".", "-read", &format!("/Users/{user}"), "NFSHomeDirectory"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + value.split_whitespace().last().map(PathBuf::from) +} + +#[cfg(target_os = "linux")] +fn lookup_home_dir(user: &str) -> Option { + let output = Command::new("getent") + .args(["passwd", user]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + value.trim().split(':').nth(5).map(PathBuf::from) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn lookup_home_dir(_user: &str) -> Option { + None +} + +#[cfg(target_os = "macos")] +fn default_data_dir() -> PathBuf { + runtime_home_dir().join("Library/Application Support/Roxy") +} + +#[cfg(target_os = "linux")] +fn default_data_dir() -> PathBuf { + runtime_home_dir().join(".local/share/roxy") +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn default_data_dir() -> PathBuf { - PathBuf::from("/etc/roxy") + runtime_home_dir().join(".roxy") +} + +#[cfg(target_os = "macos")] +fn default_runtime_dir() -> PathBuf { + runtime_home_dir().join("Library/Caches/Roxy") +} + +#[cfg(target_os = "linux")] +fn default_runtime_dir() -> PathBuf { + runtime_home_dir().join(".local/state/roxy/run") +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn default_runtime_dir() -> PathBuf { + default_data_dir().join("run") } fn default_pid_file() -> PathBuf { - PathBuf::from("/var/run/roxy.pid") + default_runtime_dir().join("roxy.pid") } +#[cfg(target_os = "macos")] fn default_log_file() -> PathBuf { - PathBuf::from("/var/log/roxy/roxy.log") + runtime_home_dir().join("Library/Logs/Roxy/roxy.log") } -fn default_certs_dir() -> PathBuf { - PathBuf::from("/etc/roxy/certs") +#[cfg(target_os = "linux")] +fn default_log_file() -> PathBuf { + runtime_home_dir().join(".local/state/roxy/roxy.log") +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn default_log_file() -> PathBuf { + default_data_dir().join("roxy.log") } fn default_socket_path() -> PathBuf { - PathBuf::from("/etc/roxy/roxy.sock") + default_runtime_dir().join("roxy.sock") +} + +/// Default user-owned configuration file. +#[cfg(target_os = "macos")] +pub fn default_config_path() -> PathBuf { + default_data_dir().join("config.toml") +} + +/// Default user-owned configuration file. +#[cfg(target_os = "linux")] +pub fn default_config_path() -> PathBuf { + runtime_home_dir().join(".config/roxy/config.toml") +} + +/// Default user-owned configuration file. +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub fn default_config_path() -> PathBuf { + default_data_dir().join("config.toml") } /// Docker integration configuration. @@ -118,9 +220,6 @@ pub struct RoxyPaths { #[serde(default = "default_log_file")] pub log_file: PathBuf, - #[serde(default = "default_certs_dir")] - pub certs_dir: PathBuf, - #[serde(default = "default_socket_path")] pub socket_path: PathBuf, } @@ -131,8 +230,25 @@ impl Default for RoxyPaths { data_dir: default_data_dir(), pid_file: default_pid_file(), log_file: default_log_file(), - certs_dir: default_certs_dir(), socket_path: default_socket_path(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_paths_are_user_owned() { + let home = runtime_home_dir(); + let paths = RoxyPaths::default(); + + assert!(default_config_path().starts_with(&home)); + assert!(paths.data_dir.starts_with(&home)); + assert!(paths.pid_file.starts_with(&home)); + assert!(paths.log_file.starts_with(&home)); + assert!(paths.socket_path.starts_with(&home)); + assert_ne!(paths.data_dir, PathBuf::from("/etc/roxy")); + } +} diff --git a/src/daemon/lifecycle.rs b/src/daemon/lifecycle.rs index e08d801..01ad770 100644 --- a/src/daemon/lifecycle.rs +++ b/src/daemon/lifecycle.rs @@ -22,6 +22,11 @@ use crate::infrastructure::tracing::{TracingOutput, init_tracing}; /// PID file management, signal handling, and server execution. #[tokio::main] pub async fn run(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { + // Load config before tracing so the configured log level applies to both + // supervisor-managed and directly launched daemon processes. + let config_store = ConfigStore::new(config_path.to_path_buf()); + let config = config_store.load()?; + // When running interactively (stdout is a TTY), log to stdout // When running as daemon (stdout is /dev/null), log to file let output = if std::io::stdout().is_terminal() { @@ -29,7 +34,7 @@ pub async fn run(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result } else { TracingOutput::File(paths.log_file.clone()) }; - init_tracing(verbose, output); + init_tracing(verbose, &config.daemon.log_level, output); info!("Roxy daemon started"); @@ -38,11 +43,6 @@ pub async fn run(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result println!("Starting Roxy daemon..."); - // 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()?; - // Create reload channel for hot-reloading registrations (nudge-based) let (reload_tx, reload_rx) = mpsc::channel::<()>(4); diff --git a/src/daemon/mgmt_socket.rs b/src/daemon/mgmt_socket.rs index ca7c6ea..f22fc62 100644 --- a/src/daemon/mgmt_socket.rs +++ b/src/daemon/mgmt_socket.rs @@ -217,7 +217,10 @@ mod tests { mpsc::Receiver<()>, tempfile::TempDir, ) { - let dir = tempfile::tempdir().unwrap(); + // Use the conventional short Unix-socket location. Platform temp + // directories can be long enough to exceed sockaddr_un limits and + // some macOS sandboxes deny socket creation there. + let dir = tempfile::tempdir_in("/tmp").unwrap(); let sock = dir.path().join("test.sock"); let state: SharedState = Arc::new(ArcSwap::from_pointee(RuntimeState::new(registrations))); @@ -231,12 +234,23 @@ mod tests { https: 443, dns: 1053, }; - tokio::spawn(async move { - let _ = serve(sock_clone, state, reload_tx, cancel_serve, test_ports).await; + let handle = tokio::spawn(async move { + serve(sock_clone, state, reload_tx, cancel_serve, test_ports).await }); - // Give the listener time to bind - tokio::time::sleep(Duration::from_millis(20)).await; + tokio::time::timeout(Duration::from_secs(1), async { + while !sock.exists() { + if handle.is_finished() { + panic!( + "management socket task exited before becoming ready: {:?}", + handle.await + ); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("management socket did not become ready"); (sock, cancel, reload_rx, dir) } @@ -317,7 +331,7 @@ mod tests { assert_eq!(domains.len(), 1); assert_eq!(domains[0]["pattern"], "app.roxy"); assert_eq!(domains[0]["source"], "config"); - assert_eq!(domains[0]["https"], false); + assert_eq!(domains[0]["https"], true); let routes = domains[0]["routes"].as_array().unwrap(); assert_eq!(routes.len(), 1); assert_eq!(routes[0]["path"], "/"); @@ -370,7 +384,13 @@ mod tests { assert!(sock.exists()); cancel.cancel(); - tokio::time::sleep(Duration::from_millis(50)).await; + tokio::time::timeout(Duration::from_secs(1), async { + while sock.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("management socket was not removed"); assert!(!sock.exists()); } } diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 2e6841a..ee5298d 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -20,6 +20,7 @@ use super::router::{RuntimeState, SharedState, create_router}; use super::tls::create_tls_acceptor; use crate::application::ports::RegistrationProvider; use crate::infrastructure::config::Config; +use crate::infrastructure::listeners::ActivatedListeners; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; @@ -44,6 +45,7 @@ pub struct Server { https_port: u16, dns_port: u16, lan_ip: Ipv4Addr, + listeners: ActivatedListeners, } impl Server { @@ -59,19 +61,13 @@ impl Server { let registrations = config.registrations(); - // Collect patterns for domains with HTTPS enabled - let https_patterns: Vec<_> = registrations - .iter() - .filter(|d| d.is_https_enabled()) - .map(|d| d.pattern().clone()) - .collect(); - let state: SharedState = Arc::new(ArcSwap::from_pointee(RuntimeState::new(registrations))); - let tls_acceptor = create_tls_acceptor(&https_patterns, &paths.certs_dir, &paths.data_dir)?; + let tls_acceptor = create_tls_acceptor(&paths.data_dir)?; // Get LAN IP for DNS responses (DNS server handles source-based resolution) let lan_ip = get_lan_ip(); + let listeners = ActivatedListeners::acquire()?; Ok(Self { state, @@ -84,10 +80,11 @@ impl Server { https_port: config.daemon.https_port, dns_port: config.daemon.dns_port, lan_ip, + listeners, }) } - pub async fn run(self, cancel: CancellationToken) -> Result<()> { + pub async fn run(mut self, cancel: CancellationToken) -> Result<()> { info!( http = self.http_port, https = self.https_port, @@ -113,8 +110,13 @@ impl Server { cancel.clone(), ); - let http_server = - start_http_server(self.state.clone(), self.http_port, cancel.clone()).await?; + let http_server = start_http_server( + self.state.clone(), + self.http_port, + self.listeners.http.take(), + cancel.clone(), + ) + .await?; await_servers( http_server, @@ -122,6 +124,7 @@ impl Server { self.state, self.tls_acceptor, self.https_port, + self.listeners.https.take(), cancel, ) .await @@ -194,6 +197,7 @@ fn spawn_mgmt_socket( async fn start_http_server( state: SharedState, http_port: u16, + activated: Option, cancel: CancellationToken, ) -> Result>> { let http_addr = SocketAddr::from(([0, 0, 0, 0], http_port)); @@ -201,10 +205,15 @@ async fn start_http_server( .layer(Extension(Scheme::Http)) .layer(axum::middleware::from_fn(inject_client_addr)); - let http_listener = TcpListener::bind(http_addr).await.context(format!( - "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", - http_port, http_port - ))?; + let http_listener = match activated { + Some(listener) => { + TcpListener::from_std(listener).context("Failed to adopt activated HTTP listener")? + } + None => TcpListener::bind(http_addr).await.context(format!( + "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", + http_port, http_port + ))?, + }; info!(addr = %http_addr, "HTTP server listening"); @@ -223,14 +232,20 @@ async fn start_https_server( state: SharedState, tls_acceptor: TlsAcceptor, https_port: u16, + activated: Option, cancel: CancellationToken, ) -> Result> { let https_addr = SocketAddr::from(([0, 0, 0, 0], https_port)); let https_router = create_router(state).layer(Extension(Scheme::Https)); - let https_listener = TcpListener::bind(https_addr).await.context(format!( - "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", - https_port, https_port - ))?; + let https_listener = match activated { + Some(listener) => { + TcpListener::from_std(listener).context("Failed to adopt activated HTTPS listener")? + } + None => TcpListener::bind(https_addr).await.context(format!( + "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", + https_port, https_port + ))?, + }; info!(addr = %https_addr, "HTTPS server listening"); @@ -281,11 +296,18 @@ async fn await_servers( state: SharedState, tls_acceptor: Option, https_port: u16, + https_listener: Option, cancel: CancellationToken, ) -> Result<()> { if let Some(tls_acceptor) = tls_acceptor { - let https_server = - start_https_server(state, tls_acceptor, https_port, cancel.clone()).await?; + let https_server = start_https_server( + state, + tls_acceptor, + https_port, + https_listener, + cancel.clone(), + ) + .await?; tokio::select! { r = http_server => r??, @@ -304,8 +326,8 @@ async fn await_servers( } } else { warn!( - "No HTTPS certificates found, running HTTP only. \ - Register a domain with sudo to enable HTTPS." + "Roxy Root CA not found, running HTTP only. \ + Run 'sudo roxy install' to enable HTTPS." ); tokio::select! { r = http_server => r??, diff --git a/src/daemon/tls.rs b/src/daemon/tls.rs index b627836..94c25c1 100644 --- a/src/daemon/tls.rs +++ b/src/daemon/tls.rs @@ -5,18 +5,20 @@ use std::sync::Arc; use std::sync::RwLock; use anyhow::{Context, Result}; -use rcgen::{Issuer, KeyPair, PKCS_ECDSA_P256_SHA256, SanType}; +use rcgen::{ + CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, Issuer, KeyPair, + KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, SanType, +}; use rustls::ServerConfig; -use rustls::pki_types::pem::PemObject; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::pki_types::PrivateKeyDer; use rustls::server::ResolvesServerCert; use rustls::sign::CertifiedKey; use tokio_rustls::TlsAcceptor; -use tracing::{info, warn}; +use tracing::warn; -use crate::domain::{DomainName, DomainPattern}; -use crate::infrastructure::certs::CertificateGenerator; -use crate::infrastructure::certs::generator::{build_ca_cert_params, build_leaf_cert_params}; +use crate::domain::DomainName; +use crate::infrastructure::certs::generator::build_ca_cert_params; +use time::{Duration, OffsetDateTime}; const ON_DEMAND_CERT_CACHE_MAX: usize = 256; @@ -27,9 +29,7 @@ const ON_DEMAND_CERT_CACHE_MAX: usize = 256; /// "Domain Not Registered" page instead of the browser showing a TLS error. #[derive(Debug)] struct DomainCertResolver { - /// All registered certificates, stored with their pattern for matching. - certs: Vec<(DomainPattern, Arc)>, - ca_key_pem: Option, + ca_key_pem: String, on_demand: RwLock>>, } @@ -42,23 +42,14 @@ impl ResolvesServerCert for DomainCertResolver { return Some(cert); } - // Find the first registered cert whose pattern matches the hostname. - // Certs are pre-sorted by specificity (most specific first). - for (pattern, cert) in &self.certs { - if pattern.matches_hostname(&hostname) { - return Some(cert.clone()); - } - } - // Generate an on-demand cert for valid `.roxy` hostnames if we // can read the local CA private key. - let ca_key_pem = self.ca_key_pem.as_deref()?; if DomainName::new(hostname.as_str()).is_err() { warn!(hostname = %hostname, "TLS: no certificate for domain"); return None; } - match generate_on_demand_certified_key(hostname.as_str(), ca_key_pem) { + match generate_on_demand_certified_key(hostname.as_str(), &self.ca_key_pem) { Ok(cert) => { if let Ok(mut cache) = self.on_demand.write() { // Bound memory: on-demand certs are cheap to regenerate. @@ -77,72 +68,21 @@ impl ResolvesServerCert for DomainCertResolver { } } -/// Load all domain certificates into a single TLS acceptor with SNI -pub fn create_tls_acceptor( - patterns: &[DomainPattern], - certs_dir: &Path, - data_dir: &Path, -) -> Result> { +/// Create a TLS acceptor that generates exact leaf certificates from SNI. +/// +/// The Root CA is trusted once during installation. Domain registration does +/// not create files or mutate a trust store. +pub fn create_tls_acceptor(data_dir: &Path) -> Result> { let ca_key_pem = match load_ca_key_pem(data_dir) { - Ok(pem) => pem, + Ok(Some(pem)) => pem, + Ok(None) => return Ok(None), Err(e) => { warn!(error = %e, "TLS: failed to load Roxy CA key (on-demand certificates disabled)"); - None + return Ok(None); } }; - // If we have neither per-domain certificates nor a Root CA to generate - // on-demand certificates, HTTPS can't be served. - if patterns.is_empty() && ca_key_pem.is_none() { - return Ok(None); - } - - let mut certs: Vec<(DomainPattern, Arc)> = Vec::new(); - - let generator = CertificateGenerator::new(data_dir.to_path_buf(), certs_dir.to_path_buf()); - - for pattern in patterns { - let stem = crate::infrastructure::certs::cert_name(pattern); - let cert_path = certs_dir.join(format!("{}.crt", stem)); - let key_path = certs_dir.join(format!("{}.key", stem)); - - if !cert_path.exists() || !key_path.exists() { - info!(domain = %pattern, "Certificate missing, generating automatically"); - match generator.generate(pattern) { - Ok(cert) => { - if let Err(e) = generator.save(&cert) { - warn!(domain = %pattern, error = %e, "Failed to save auto-generated certificate, skipping HTTPS for this domain"); - continue; - } - } - Err(e) => { - warn!(domain = %pattern, error = %e, "Failed to auto-generate certificate, skipping HTTPS for this domain"); - continue; - } - } - } - - let loaded_certs = load_certs(&cert_path)?; - let key = load_private_key(&key_path)?; - - let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key) - .context("Failed to create signing key")?; - - let certified_key = Arc::new(CertifiedKey::new(loaded_certs, signing_key)); - certs.push((pattern.clone(), certified_key)); - } - - // If all certificate loads/generations failed and we have no CA key for - // on-demand certs, HTTPS can't serve anything — don't start the listener. - if certs.is_empty() && ca_key_pem.is_none() { - return Ok(None); - } - - // Most-specific pattern wins (longest base domain). - certs.sort_by_key(|(p, _)| std::cmp::Reverse(p.specificity())); - let resolver = Arc::new(DomainCertResolver { - certs, ca_key_pem, on_demand: RwLock::new(HashMap::new()), }); @@ -194,16 +134,89 @@ fn generate_on_demand_certified_key(hostname: &str, ca_key_pem: &str) -> Result< Ok(Arc::new(CertifiedKey::new(certs, signing_key))) } -fn load_certs(path: &Path) -> Result>> { - let certs: Vec<_> = CertificateDer::pem_file_iter(path) - .with_context(|| format!("Failed to open cert file: {}", path.display()))? - .collect::, _>>() - .context("Failed to parse certificates")?; - - Ok(certs) +fn build_leaf_cert_params(common_name: &str, sans: Vec) -> CertificateParams { + let mut params = CertificateParams::default(); + params.subject_alt_names = sans; + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, common_name); + params.distinguished_name = distinguished_name; + params.not_before = OffsetDateTime::now_utc() - Duration::days(1); + params.not_after = OffsetDateTime::now_utc() + Duration::days(825); + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params } -fn load_private_key(path: &Path) -> Result> { - PrivateKeyDer::from_pem_file(path) - .with_context(|| format!("Failed to load private key from: {}", path.display())) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_ca_disables_tls() { + let directory = tempfile::tempdir().unwrap(); + assert!(create_tls_acceptor(directory.path()).unwrap().is_none()); + } + + #[test] + fn creates_an_in_memory_leaf_key() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let certified = + generate_on_demand_certified_key("app.roxy", &ca_key.serialize_pem()).unwrap(); + + assert_eq!(certified.cert.len(), 1); + assert!(!certified.cert[0].as_ref().is_empty()); + } + + #[test] + fn invalid_ca_key_is_rejected() { + let error = generate_on_demand_certified_key("app.roxy", "not a key").unwrap_err(); + assert!(error.to_string().contains("Failed to parse CA key")); + } + + #[tokio::test] + async fn generated_leaf_chains_to_a_legacy_installed_ca() { + use rustls::pki_types::ServerName; + use rustls::{ClientConfig, RootCertStore}; + use tokio_rustls::TlsConnector; + + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut legacy_params = CertificateParams::default(); + let mut legacy_name = DistinguishedName::new(); + legacy_name.push(DnType::CommonName, "Roxy Local Development CA"); + legacy_name.push(DnType::OrganizationName, "Roxy"); + legacy_params.distinguished_name = legacy_name; + legacy_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + legacy_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let ca_cert = legacy_params.self_signed(&ca_key).unwrap(); + + let resolver = Arc::new(DomainCertResolver { + ca_key_pem: ca_key.serialize_pem(), + on_demand: RwLock::new(HashMap::new()), + }); + let server = TlsAcceptor::from(Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver), + )); + + let mut roots = RootCertStore::empty(); + roots.add(ca_cert.der().clone()).unwrap(); + let client = TlsConnector::from(Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + )); + let server_name = ServerName::try_from("app.roxy").unwrap(); + let (client_io, server_io) = tokio::io::duplex(16 * 1024); + + let (client_result, server_result) = tokio::join!( + client.connect(server_name, client_io), + server.accept(server_io) + ); + client_result.unwrap(); + server_result.unwrap(); + } } diff --git a/src/domain/registration.rs b/src/domain/registration.rs index 61df5fe..0609272 100644 --- a/src/domain/registration.rs +++ b/src/domain/registration.rs @@ -46,7 +46,7 @@ impl DomainRegistration { Self { pattern, routes, - https_enabled: false, + https_enabled: true, source: RegistrationSource::Config, } } @@ -60,7 +60,7 @@ impl DomainRegistration { Self { pattern, routes, - https_enabled: false, + https_enabled: true, source, } } @@ -168,9 +168,9 @@ mod tests { // --- Constructor --- #[test] - fn new_creates_registration_with_https_disabled() { + fn new_creates_https_registration() { let reg = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); - assert!(!reg.is_https_enabled()); + assert!(reg.is_https_enabled()); assert_eq!(reg.routes().len(), 1); assert_eq!(reg.domain().as_str(), "myapp.roxy"); } @@ -191,7 +191,7 @@ mod tests { fn enable_https_sets_flag() { let mut reg = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); - assert!(!reg.is_https_enabled()); + assert!(reg.is_https_enabled()); reg.enable_https(); assert!(reg.is_https_enabled()); } diff --git a/src/infrastructure/certs/ca.rs b/src/infrastructure/certs/ca.rs index 0929c8e..9d92296 100644 --- a/src/infrastructure/certs/ca.rs +++ b/src/infrastructure/certs/ca.rs @@ -1,4 +1,4 @@ -use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, PKCS_ECDSA_P256_SHA256}; +use rcgen::{BasicConstraints, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256}; use std::fs; use std::path::PathBuf; @@ -79,36 +79,6 @@ impl RootCA { Ok(()) } - /// Load the CA key pair for signing - pub fn load_key_pair(&self) -> Result { - let key_pem = fs::read_to_string(self.key_path()).map_err(|e| CertError::ReadError { - path: self.key_path(), - source: e, - })?; - - KeyPair::from_pem(&key_pem).map_err(|e| CertError::GenerationError(e.to_string())) - } - - /// Sign a certificate with this CA - /// Returns the signed certificate PEM - pub fn sign_certificate( - &self, - params: CertificateParams, - key_pair: &KeyPair, - ) -> Result { - let ca_key_pair = self.load_key_pair()?; - - let ca_params = super::generator::build_ca_cert_params(); - let issuer = Issuer::from_params(&ca_params, &ca_key_pair); - - // Sign the domain certificate - let cert = params - .signed_by(key_pair, &issuer) - .map_err(|e| CertError::GenerationError(e.to_string()))?; - - Ok(cert.pem()) - } - /// Delete the CA certificate and key (used by uninstall) pub fn delete(&self) -> Result<(), CertError> { let cert_path = self.cert_path(); diff --git a/src/infrastructure/certs/generator.rs b/src/infrastructure/certs/generator.rs index be03a64..87573e2 100644 --- a/src/infrastructure/certs/generator.rs +++ b/src/infrastructure/certs/generator.rs @@ -1,222 +1,19 @@ -use rcgen::{ - CertificateParams, DistinguishedName, DnType, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, - SanType, -}; -use std::fs; -use std::path::PathBuf; +use rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, KeyUsagePurpose}; use time::{Duration, OffsetDateTime}; -use super::CertError; -use super::ca::RootCA; -use crate::domain::DomainPattern; - -/// Represents a generated certificate with its key pair -pub struct Certificate { - /// File stem used for saving (e.g. "myapp.roxy" or "__wildcard__.myapp.roxy") - pub file_stem: String, - pub cert_pem: String, - pub key_pem: String, -} - -/// Build certificate parameters for a domain leaf certificate. -/// -/// Sets up the Distinguished Name, 1-year validity, key usage for -/// server authentication, and the given Subject Alternative Names. -pub(crate) fn build_leaf_cert_params(common_name: &str, sans: Vec) -> CertificateParams { - let mut params = CertificateParams::default(); - - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, common_name); - dn.push(DnType::OrganizationName, "Roxy Local Development"); - params.distinguished_name = dn; - - let now = OffsetDateTime::now_utc(); - params.not_before = now; - params.not_after = now + Duration::days(365); - - params.subject_alt_names = sans; - - params.key_usages = vec![ - KeyUsagePurpose::DigitalSignature, - KeyUsagePurpose::KeyEncipherment, - ]; - - params -} - /// Build the standard Roxy CA certificate parameters. pub(crate) fn build_ca_cert_params() -> CertificateParams { let mut params = CertificateParams::default(); - - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Roxy Local Development CA"); - dn.push(DnType::OrganizationName, "Roxy"); - params.distinguished_name = dn; - + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, "Roxy Local Development CA"); + // Keep this identity byte-for-byte compatible with CAs created by older + // Roxy versions; the daemon reconstructs the issuer from these params. + distinguished_name.push(DnType::OrganizationName, "Roxy"); + params.distinguished_name = distinguished_name; + params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); let now = OffsetDateTime::now_utc(); params.not_before = now; params.not_after = now + Duration::days(3650); - params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; - params } - -pub struct CertificateGenerator { - base_dir: PathBuf, - certs_dir: PathBuf, -} - -impl CertificateGenerator { - /// Create a CertificateGenerator with explicit directories - pub fn new(base_dir: PathBuf, certs_dir: PathBuf) -> Self { - Self { - base_dir, - certs_dir, - } - } - - /// Generate a certificate for the given domain pattern, signed by the Root CA. - /// - /// For exact patterns, generates a single-domain cert. - /// For wildcard patterns, generates a cert with SANs for base + *.base. - pub fn generate(&self, pattern: &DomainPattern) -> Result { - let ca = RootCA::new(self.base_dir.clone()); - - if !ca.exists() { - return Err(CertError::GenerationError( - "Root CA not found. Run 'sudo roxy install' first.".to_string(), - )); - } - - // Generate ECDSA P-256 key pair - let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) - .map_err(|e| CertError::GenerationError(e.to_string()))?; - - let sans = build_sans(pattern)?; - let params = build_leaf_cert_params(pattern.base_domain().as_str(), sans); - - // Sign certificate with CA - let cert_pem = ca.sign_certificate(params, &key_pair)?; - - Ok(Certificate { - file_stem: super::cert_name(pattern), - cert_pem, - key_pem: key_pair.serialize_pem(), - }) - } - - /// Save a certificate to disk - 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(), - source: e, - })?; - - let cert_path = self.certs_dir.join(format!("{}.crt", cert.file_stem)); - let key_path = self.certs_dir.join(format!("{}.key", cert.file_stem)); - - // Write certificate - fs::write(&cert_path, &cert.cert_pem).map_err(|e| CertError::WriteError { - path: cert_path.clone(), - source: e, - })?; - - // Write private key with restricted permissions - fs::write(&key_path, &cert.key_pem).map_err(|e| CertError::WriteError { - path: key_path.clone(), - source: e, - })?; - - // Set key file permissions to 0600 (owner read/write only) - crate::infrastructure::file_security::restrict_key_permissions(&key_path).map_err(|e| { - CertError::WriteError { - path: key_path.clone(), - source: e, - } - })?; - - Ok(()) - } - - /// Delete certificate files for a domain pattern - pub fn delete(&self, pattern: &DomainPattern) -> Result<(), CertError> { - let stem = super::cert_name(pattern); - let cert_path = self.certs_dir.join(format!("{}.crt", stem)); - let key_path = self.certs_dir.join(format!("{}.key", stem)); - - if cert_path.exists() { - fs::remove_file(&cert_path).map_err(|e| CertError::DeleteError { - path: cert_path, - source: e, - })?; - } - - if key_path.exists() { - fs::remove_file(&key_path).map_err(|e| CertError::DeleteError { - path: key_path, - source: e, - })?; - } - - Ok(()) - } - - /// Check if certificate exists for a domain pattern - pub fn exists(&self, pattern: &DomainPattern) -> bool { - let stem = super::cert_name(pattern); - let cert_path = self.certs_dir.join(format!("{}.crt", stem)); - let key_path = self.certs_dir.join(format!("{}.key", stem)); - cert_path.exists() && key_path.exists() - } -} - -/// Build Subject Alternative Names for the given pattern. -fn build_sans(pattern: &DomainPattern) -> Result, CertError> { - let base_str = pattern.base_domain().as_str(); - - let base_san = - SanType::DnsName(base_str.try_into().map_err(|e| { - CertError::GenerationError(format!("Invalid domain name for SAN: {}", e)) - })?); - - match pattern { - DomainPattern::Exact(_) => Ok(vec![base_san]), - DomainPattern::Wildcard(_) => { - let wildcard_str = format!("*.{}", base_str); - let wildcard_san = SanType::DnsName(wildcard_str.try_into().map_err(|e| { - CertError::GenerationError(format!("Invalid wildcard name for SAN: {}", e)) - })?); - Ok(vec![base_san, wildcard_san]) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::DomainName; - use crate::infrastructure::certs::ca::RootCA; - use tempfile::TempDir; - - #[test] - fn test_certificate_generation() { - 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"); - - let ca = RootCA::new(base_dir.clone()); - ca.generate().expect("Failed to generate test CA"); - - let domain = DomainName::new("test.roxy").unwrap(); - let pattern = DomainPattern::Exact(domain); - let generator = CertificateGenerator::new(base_dir, certs_dir); - - let cert = generator.generate(&pattern).unwrap(); - - assert_eq!(cert.file_stem, "test.roxy"); - assert!(cert.cert_pem.contains("BEGIN CERTIFICATE")); - assert!(cert.key_pem.contains("BEGIN PRIVATE KEY")); - } -} diff --git a/src/infrastructure/certs/mod.rs b/src/infrastructure/certs/mod.rs index b366723..4e19d0e 100644 --- a/src/infrastructure/certs/mod.rs +++ b/src/infrastructure/certs/mod.rs @@ -1,36 +1,13 @@ use std::path::PathBuf; use thiserror::Error; -use crate::domain::DomainPattern; - pub mod ca; pub mod generator; pub mod service; pub mod trust_store; -pub use generator::CertificateGenerator; pub use service::CertificateService; -/// Filename prefix for wildcard certificates stored on disk. -/// -/// Uses underscores so it can't collide with a valid `.roxy` domain -/// (underscores are rejected by `DomainName` validation). -pub const WILDCARD_CERT_PREFIX: &str = "__wildcard__."; - -/// Certificate file stem used for on-disk certificate naming. -/// -/// Exact domains use the domain directly (`myapp.roxy`). -/// Wildcard domains use the `__wildcard__.` prefix -/// (`__wildcard__.myapp.roxy`). -pub fn cert_name(pattern: &DomainPattern) -> String { - match pattern { - DomainPattern::Exact(d) => d.as_str().to_string(), - DomainPattern::Wildcard(d) => { - format!("{}{}", WILDCARD_CERT_PREFIX, d.as_str()) - } - } -} - #[derive(Error, Debug)] pub enum CertError { #[error("Failed to generate certificate: {0}")] @@ -42,12 +19,6 @@ pub enum CertError { source: std::io::Error, }, - #[error("Failed to read certificate from {path}: {source}")] - ReadError { - path: PathBuf, - source: std::io::Error, - }, - #[error("Failed to delete certificate at {path}: {source}")] DeleteError { path: PathBuf, @@ -58,27 +29,7 @@ pub enum CertError { TrustStoreError(String), #[error( - "Permission denied. Trust store modification requires root privileges.\nRun with: sudo roxy register ..." + "Permission denied. Trust store modification requires root privileges.\nRun with: sudo roxy install" )] PermissionDenied, } - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::DomainName; - - #[test] - fn exact_cert_name() { - let name = DomainName::new("myapp.roxy").unwrap(); - let pattern = DomainPattern::Exact(name); - assert_eq!(cert_name(&pattern), "myapp.roxy"); - } - - #[test] - fn wildcard_cert_name() { - let name = DomainName::new("myapp.roxy").unwrap(); - let pattern = DomainPattern::Wildcard(name); - assert_eq!(cert_name(&pattern), "__wildcard__.myapp.roxy"); - } -} diff --git a/src/infrastructure/certs/service.rs b/src/infrastructure/certs/service.rs index 7a91d0f..a8fec1c 100644 --- a/src/infrastructure/certs/service.rs +++ b/src/infrastructure/certs/service.rs @@ -1,13 +1,11 @@ +use super::CertError; use super::ca::RootCA; use super::trust_store::{TrustStore, get_trust_store}; -use super::{CertError, CertificateGenerator}; use crate::application::ports::{CertificateError, CertificateManager}; -use crate::domain::DomainPattern; use crate::infrastructure::paths::RoxyPaths; /// High-level service for certificate operations pub struct CertificateService { - generator: CertificateGenerator, ca: RootCA, } @@ -15,21 +13,18 @@ impl CertificateService { /// Create a new CertificateService with paths from RoxyPaths pub fn new(paths: &RoxyPaths) -> Self { Self { - generator: CertificateGenerator::new(paths.data_dir.clone(), paths.certs_dir.clone()), ca: RootCA::new(paths.data_dir.clone()), } } /// Initialize the Root CA (called during `roxy install`) pub fn init_ca(&self) -> Result<(), CertError> { - if self.ca.exists() { - return Ok(()); + if !self.ca.exists() { + self.ca.generate()?; } - // Generate CA - self.ca.generate()?; - - // Add CA to trust store + // Trust installation is idempotent and is the only privileged + // certificate operation Roxy performs. let trust_store = get_trust_store()?; trust_store.add_ca(&self.ca.cert_path())?; @@ -46,32 +41,6 @@ impl CertificateService { trust_store.is_ca_trusted() } - /// Generate a certificate for a domain pattern (signed by the Root CA). - /// - /// For exact patterns, generates a single-domain cert. - /// For wildcard patterns, generates a cert with SANs for base + *.base. - pub fn create_and_install(&self, pattern: &DomainPattern) -> Result<(), CertError> { - if !self.ca.exists() { - return Err(CertError::GenerationError( - "Root CA not found. Run 'sudo roxy install' first.".to_string(), - )); - } - - let cert = self.generator.generate(pattern)?; - self.generator.save(&cert)?; - Ok(()) - } - - /// Remove certificate files for a domain pattern. - pub fn remove(&self, pattern: &DomainPattern) -> Result<(), CertError> { - self.generator.delete(pattern) - } - - /// Check if certificate exists for a domain pattern. - pub fn exists(&self, pattern: &DomainPattern) -> bool { - self.generator.exists(pattern) - } - /// Check if certificate is trusted (CA is trusted = all certs trusted) pub fn is_trusted(&self) -> Result { self.is_ca_installed() @@ -96,24 +65,10 @@ impl CertificateManager for CertificateService { .map_err(|e| CertificateError::OperationFailed(e.into())) } - fn create_and_install(&self, pattern: &DomainPattern) -> Result<(), CertificateError> { - CertificateService::create_and_install(self, pattern) - .map_err(|e| CertificateError::OperationFailed(e.into())) - } - - fn remove(&self, pattern: &DomainPattern) -> Result<(), CertificateError> { - CertificateService::remove(self, pattern) - .map_err(|e| CertificateError::OperationFailed(e.into())) - } - fn remove_ca(&self) -> Result<(), CertificateError> { CertificateService::remove_ca(self).map_err(|e| CertificateError::OperationFailed(e.into())) } - fn exists(&self, pattern: &DomainPattern) -> bool { - CertificateService::exists(self, pattern) - } - fn is_trusted(&self) -> Result { CertificateService::is_trusted(self) .map_err(|e| CertificateError::OperationFailed(e.into())) diff --git a/src/infrastructure/config/dto.rs b/src/infrastructure/config/dto.rs index 657feaa..4a3f67d 100644 --- a/src/infrastructure/config/dto.rs +++ b/src/infrastructure/config/dto.rs @@ -33,9 +33,10 @@ impl From for RegistrationDto { impl From for DomainRegistration { fn from(dto: RegistrationDto) -> Self { let mut reg = DomainRegistration::new(dto.pattern, dto.routes); - if dto.https_enabled { - reg.enable_https(); - } + // HTTPS availability is now global: the daemon signs an exact leaf + // certificate from SNI whenever the Root CA is installed. The legacy + // field remains readable for config compatibility. + reg.enable_https(); reg } } diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index 3437251..0804df7 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -131,7 +131,9 @@ impl ConfigStore { self.ensure_config_dir()?; let content = toml::to_string_pretty(config)?; - fs::write(&self.path, content)?; + let temporary = self.path.with_extension("toml.tmp"); + fs::write(&temporary, content)?; + fs::rename(temporary, &self.path)?; Ok(()) } diff --git a/src/infrastructure/filesystem.rs b/src/infrastructure/filesystem.rs index 7b90f6b..ac442bb 100644 --- a/src/infrastructure/filesystem.rs +++ b/src/infrastructure/filesystem.rs @@ -24,14 +24,6 @@ impl SystemSetup for FileSystemSetup<'_> { ) })?; - fs::create_dir_all(&self.paths.certs_dir).map_err(|e| { - anyhow::anyhow!( - "Failed to create certs directory {}: {}", - self.paths.certs_dir.display(), - e - ) - })?; - if let Some(log_dir) = self.paths.log_file.parent() { fs::create_dir_all(log_dir).map_err(|e| { anyhow::anyhow!( diff --git a/src/infrastructure/listeners.rs b/src/infrastructure/listeners.rs new file mode 100644 index 0000000..8d3360e --- /dev/null +++ b/src/infrastructure/listeners.rs @@ -0,0 +1,178 @@ +//! Privileged listener acquisition from the operating-system service manager. +//! +//! launchd/systemd own ports 80 and 443 and pass the already-bound descriptors +//! to Roxy. The rest of the daemon can therefore run as the developer user. + +use std::net::TcpListener; + +use anyhow::Result; + +#[derive(Default)] +pub struct ActivatedListeners { + pub http: Option, + pub https: Option, +} + +impl ActivatedListeners { + pub fn acquire() -> Result { + platform::acquire() + } +} + +#[cfg(target_os = "linux")] +mod platform { + use std::env; + use std::os::fd::{FromRawFd, RawFd}; + use std::process; + + use anyhow::{Context, Result, bail}; + + use super::{ActivatedListeners, TcpListener}; + + const FIRST_ACTIVATION_FD: RawFd = 3; + + pub fn acquire() -> Result { + let Some(listen_pid) = env::var("LISTEN_PID").ok() else { + return Ok(ActivatedListeners::default()); + }; + if listen_pid.parse::().ok() != Some(process::id()) { + return Ok(ActivatedListeners::default()); + } + + let count = env::var("LISTEN_FDS") + .context("LISTEN_PID is set but LISTEN_FDS is missing")? + .parse::() + .context("LISTEN_FDS is not a valid descriptor count")?; + if count > 2 { + bail!("Roxy expected at most two activated listeners, received {count}"); + } + let names = env::var("LISTEN_FDNAMES").unwrap_or_default(); + let names: Vec<_> = names.split(':').collect(); + let mut listeners = ActivatedListeners::default(); + + for index in 0..count { + let fd = FIRST_ACTIVATION_FD + index as RawFd; + let name = names.get(index).copied().unwrap_or(match index { + 0 => "http", + 1 => "https", + _ => "unknown", + }); + let listener = listener_from_owned_fd(fd)?; + + match name { + "http" => listeners.http = Some(listener), + "https" => listeners.https = Some(listener), + _ => drop(listener), + } + } + + Ok(listeners) + } + + fn listener_from_owned_fd(fd: RawFd) -> Result { + // SAFETY: F_GETFD only inspects the numeric descriptor and does not + // dereference pointers. A failure lets us reject spoofed or stale + // socket-activation environment variables before taking ownership. + if unsafe { libc::fcntl(fd, libc::F_GETFD) } == -1 { + bail!("Activated descriptor {fd} is not open"); + } + + // SAFETY: systemd promises that descriptors starting at 3 are valid, + // uniquely owned when LISTEN_PID matches this process; the F_GETFD + // check above verifies the descriptor exists. This function is called + // at most once and immediately takes ownership. + let listener = unsafe { TcpListener::from_raw_fd(fd) }; + listener + .set_nonblocking(true) + .context("Failed to make activated listener non-blocking")?; + if listener.local_addr().is_err() { + bail!("Activated descriptor {fd} is not a TCP listener"); + } + Ok(listener) + } +} + +#[cfg(target_os = "macos")] +mod platform { + use std::ffi::CString; + use std::os::fd::{FromRawFd, OwnedFd}; + use std::os::raw::{c_char, c_int}; + use std::ptr; + + use anyhow::{Context, Result, bail}; + + use super::{ActivatedListeners, TcpListener}; + + #[link(name = "System")] + unsafe extern "C" { + fn launch_activate_socket( + name: *const c_char, + fds: *mut *mut c_int, + count: *mut usize, + ) -> c_int; + } + + pub fn acquire() -> Result { + Ok(ActivatedListeners { + http: activate("Http")?, + https: activate("Https")?, + }) + } + + fn activate(name: &str) -> Result> { + let name = CString::new(name).context("launchd socket name contains NUL")?; + let mut raw_fds: *mut c_int = ptr::null_mut(); + let mut count = 0_usize; + + // SAFETY: `name` is NUL-terminated, both output pointers are valid for + // writes, and launchd allocates the returned descriptor array. On + // success each descriptor is uniquely transferred to this process. + let result = unsafe { launch_activate_socket(name.as_ptr(), &mut raw_fds, &mut count) }; + if result == libc::ENOENT || result == libc::ESRCH { + return Ok(None); + } + if result != 0 { + bail!("launch_activate_socket failed with errno {result}"); + } + if raw_fds.is_null() || count == 0 { + return Ok(None); + } + + // SAFETY: launchd returned `count` initialized descriptors in an array + // allocated with malloc. Copying the integers does not outlive the array. + let descriptors = unsafe { std::slice::from_raw_parts(raw_fds, count) }.to_vec(); + // SAFETY: launchd documents that callers own and must free this array. + unsafe { libc::free(raw_fds.cast()) }; + + let mut owned: Vec = descriptors + .into_iter() + .map(|fd| { + // SAFETY: each descriptor was transferred by launchd exactly + // once and is now represented by one OwnedFd. + unsafe { OwnedFd::from_raw_fd(fd) } + }) + .collect(); + let Some(fd) = owned.pop() else { + return Ok(None); + }; + let listener = TcpListener::from(fd); + listener + .set_nonblocking(true) + .context("Failed to make activated listener non-blocking")?; + listener + .local_addr() + .context("Activated descriptor is not a TCP listener")?; + Ok(Some(listener)) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod platform { + use anyhow::Result; + + use super::ActivatedListeners; + + pub fn acquire() -> Result { + Ok(ActivatedListeners::default()) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 8050b82..c7874d4 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -4,9 +4,11 @@ pub mod dns; pub mod docker; pub mod file_security; pub mod filesystem; +pub mod listeners; pub mod mgmt_client; pub mod network; pub mod paths; pub mod pid; pub mod process; +pub mod service; pub mod tracing; diff --git a/src/infrastructure/process/unix.rs b/src/infrastructure/process/unix.rs index a8720a0..e8424d4 100644 --- a/src/infrastructure/process/unix.rs +++ b/src/infrastructure/process/unix.rs @@ -1,5 +1,5 @@ use std::process::Command; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Result; @@ -26,7 +26,10 @@ impl ProcessControl for UnixProcessControl { anyhow::bail!("Failed to send SIGTERM to pid {pid}: {stderr}"); } - std::thread::sleep(timeout); + let deadline = Instant::now() + timeout; + while self.process_exists(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(25)); + } if self.process_exists(pid) { let output = Command::new("kill") diff --git a/src/infrastructure/service/linux.rs b/src/infrastructure/service/linux.rs new file mode 100644 index 0000000..20add11 --- /dev/null +++ b/src/infrastructure/service/linux.rs @@ -0,0 +1,128 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use super::RuntimeUser; +use crate::config::DaemonConfig; +use crate::infrastructure::paths::RoxyPaths; + +const SERVICE_PATH: &str = "/etc/systemd/system/roxy.service"; +const HTTP_SOCKET_PATH: &str = "/etc/systemd/system/roxy-http.socket"; +const HTTPS_SOCKET_PATH: &str = "/etc/systemd/system/roxy-https.socket"; + +pub fn install( + executable: &Path, + config_path: &Path, + _paths: &RoxyPaths, + daemon: &DaemonConfig, + user: &RuntimeUser, +) -> Result<()> { + fs::write( + HTTP_SOCKET_PATH, + render_socket("HTTP", daemon.http_port, "http"), + )?; + fs::write( + HTTPS_SOCKET_PATH, + render_socket("HTTPS", daemon.https_port, "https"), + )?; + fs::write(SERVICE_PATH, render_service(executable, config_path, user))?; + + run_systemctl(["daemon-reload"])?; + run_systemctl(["stop", "roxy.service"])?; + run_systemctl([ + "enable", + "roxy-http.socket", + "roxy-https.socket", + "roxy.service", + ])?; + run_systemctl(["restart", "roxy-http.socket", "roxy-https.socket"])?; + run_systemctl(["start", "roxy.service"])?; + Ok(()) +} + +pub fn uninstall() -> Result<()> { + let _ = Command::new("systemctl") + .args([ + "disable", + "--now", + "roxy.service", + "roxy-http.socket", + "roxy-https.socket", + ]) + .output(); + for path in [SERVICE_PATH, HTTP_SOCKET_PATH, HTTPS_SOCKET_PATH] { + if Path::new(path).exists() { + fs::remove_file(path).with_context(|| format!("Failed to remove {path}"))?; + } + } + run_systemctl(["daemon-reload"])?; + Ok(()) +} + +pub fn is_installed() -> bool { + Path::new(SERVICE_PATH).exists() + && Path::new(HTTP_SOCKET_PATH).exists() + && Path::new(HTTPS_SOCKET_PATH).exists() +} + +fn render_socket(description: &str, port: u16, name: &str) -> String { + format!( + "[Unit]\nDescription=Roxy {description} socket\n\n\ + [Socket]\nListenStream=0.0.0.0:{port}\nFileDescriptorName={name}\nService=roxy.service\n\n\ + [Install]\nWantedBy=sockets.target\n" + ) +} + +fn render_service(executable: &Path, config_path: &Path, user: &RuntimeUser) -> String { + format!( + "[Unit]\nDescription=Roxy local development proxy\nAfter=network.target\n\ + Requires=roxy-http.socket roxy-https.socket\n\n\ + [Service]\nType=simple\nUser={}\nExecStart={} --config {} start --foreground\n\ + Restart=on-failure\nNoNewPrivileges=true\n\n\ + [Install]\nWantedBy=multi-user.target\n", + user.name, + unit_arg(&executable.display().to_string()), + unit_arg(&config_path.display().to_string()), + ) +} + +fn unit_arg(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn run_systemctl(args: [&str; N]) -> Result<()> { + let output = Command::new("systemctl") + .args(args) + .output() + .context("Failed to run systemctl")?; + if !output.status.success() { + bail!( + "systemctl failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_runs_as_runtime_user() { + let unit = render_service( + Path::new("/usr/local/bin/roxy"), + Path::new("/home/dev/.config/roxy/config.toml"), + &RuntimeUser { + name: "dev".into(), + uid: 1000, + gid: 1000, + home: "/home/dev".into(), + }, + ); + assert!(unit.contains("User=dev")); + assert!(unit.contains("NoNewPrivileges=true")); + } +} diff --git a/src/infrastructure/service/macos.rs b/src/infrastructure/service/macos.rs new file mode 100644 index 0000000..120e8f0 --- /dev/null +++ b/src/infrastructure/service/macos.rs @@ -0,0 +1,162 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use super::RuntimeUser; +use crate::config::DaemonConfig; +use crate::infrastructure::paths::RoxyPaths; + +const LABEL: &str = "com.roxy.proxy"; +const PLIST_PATH: &str = "/Library/LaunchDaemons/com.roxy.proxy.plist"; + +pub fn install( + executable: &Path, + config_path: &Path, + _paths: &RoxyPaths, + daemon: &DaemonConfig, + user: &RuntimeUser, +) -> Result<()> { + remove_legacy_services()?; + + let plist = render_plist(executable, config_path, daemon, user); + fs::write(PLIST_PATH, plist).context("Failed to write Roxy launchd service")?; + + let _ = Command::new("launchctl") + .args(["bootout", &format!("system/{LABEL}")]) + .output(); + run_launchctl(["bootstrap", "system", PLIST_PATH])?; + run_launchctl(["enable", &format!("system/{LABEL}")])?; + run_launchctl(["kickstart", "-k", &format!("system/{LABEL}")])?; + Ok(()) +} + +fn remove_legacy_services() -> Result<()> { + for (label, path) in [ + ("cz.rbas.roxy", "/Library/LaunchDaemons/cz.rbas.roxy.plist"), + ( + "homebrew.mxcl.roxy", + "/Library/LaunchDaemons/homebrew.mxcl.roxy.plist", + ), + ] { + let _ = Command::new("launchctl") + .args(["bootout", &format!("system/{label}")]) + .output(); + if Path::new(path).exists() { + fs::remove_file(path).with_context(|| format!("Failed to remove legacy {path}"))?; + } + } + Ok(()) +} + +pub fn uninstall() -> Result<()> { + let _ = Command::new("launchctl") + .args(["bootout", &format!("system/{LABEL}")]) + .output(); + if Path::new(PLIST_PATH).exists() { + fs::remove_file(PLIST_PATH).context("Failed to remove Roxy launchd service")?; + } + remove_legacy_services()?; + Ok(()) +} + +pub fn is_installed() -> bool { + Path::new(PLIST_PATH).exists() +} + +fn render_plist( + executable: &Path, + config_path: &Path, + daemon: &DaemonConfig, + user: &RuntimeUser, +) -> String { + format!( + r#" + + + + Label{LABEL} + ProgramArguments + + {} + --config + {} + start + --foreground + + UserName{} + RunAtLoad + Sockets + + Http + + SockNodeName0.0.0.0 + SockServiceName{} + SockTypestream + SockFamilyIPv4 + + Https + + SockNodeName0.0.0.0 + SockServiceName{} + SockTypestream + SockFamilyIPv4 + + + + +"#, + xml_escape(&executable.display().to_string()), + xml_escape(&config_path.display().to_string()), + xml_escape(&user.name), + daemon.http_port, + daemon.https_port, + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn run_launchctl(args: [&str; N]) -> Result<()> { + let output = Command::new("launchctl") + .args(args) + .output() + .context("Failed to run launchctl")?; + if !output.status.success() { + bail!( + "launchctl failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plist_contains_unprivileged_user_and_sockets() { + let plist = render_plist( + Path::new("/usr/local/bin/roxy"), + Path::new("/Users/dev/Library/Application Support/Roxy/config.toml"), + &DaemonConfig::default(), + &RuntimeUser { + name: "dev".into(), + uid: 501, + gid: 20, + home: "/Users/dev".into(), + }, + ); + assert!(plist.contains("UserNamedev")); + assert!(plist.contains("Http")); + assert!(plist.contains("443")); + } +} diff --git a/src/infrastructure/service/mod.rs b/src/infrastructure/service/mod.rs new file mode 100644 index 0000000..e6a1110 --- /dev/null +++ b/src/infrastructure/service/mod.rs @@ -0,0 +1,339 @@ +//! Installation of the OS-managed, unprivileged Roxy daemon. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; + +use std::env; +use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use crate::config::DaemonConfig; +use crate::infrastructure::paths::RoxyPaths; + +#[derive(Debug, Clone)] +pub struct RuntimeUser { + pub name: String, + pub uid: u32, + pub gid: u32, + pub home: PathBuf, +} + +impl RuntimeUser { + fn detect() -> Result { + ensure_root()?; + + let name = env::var("SUDO_USER") + .ok() + .filter(|name| name != "root") + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot determine the developer account. Run 'sudo roxy install' \ + from the account that should own Roxy." + ) + })?; + let uid = env::var("SUDO_UID") + .context("sudo did not provide SUDO_UID")? + .parse() + .context("SUDO_UID is invalid")?; + let gid = env::var("SUDO_GID") + .context("sudo did not provide SUDO_GID")? + .parse() + .context("SUDO_GID is invalid")?; + + Ok(Self { + name, + uid, + gid, + home: crate::config::runtime_home_dir(), + }) + } +} + +pub fn install( + config_path: &Path, + paths: &RoxyPaths, + daemon: &DaemonConfig, +) -> Result { + let user = RuntimeUser::detect()?; + transfer_runtime_ownership(config_path, paths, &user)?; + + let executable = service_executable()?; + platform_install(&executable, config_path, paths, daemon, &user)?; + Ok(user) +} + +/// Prefer the path used to invoke Roxy when it resolves to this process. This +/// preserves stable package-manager symlinks across upgrades instead of +/// embedding a versioned Cellar or installation path in the service unit. +fn service_executable() -> Result { + let current = env::current_exe().context("Failed to locate the Roxy executable")?; + let canonical_current = current.canonicalize().unwrap_or_else(|_| current.clone()); + let Some(invoked) = env::args_os().next().map(PathBuf::from) else { + return Ok(current); + }; + + let mut candidates: Vec = if invoked.components().count() > 1 { + if invoked.is_absolute() { + vec![invoked] + } else { + vec![ + env::current_dir() + .context("Failed to resolve the invoked Roxy path")? + .join(invoked), + ] + } + } else { + env::var_os("PATH") + .map(|path| { + env::split_paths(&path) + .map(|dir| dir.join(&invoked)) + .collect() + }) + .unwrap_or_default() + }; + candidates.extend( + [ + "/opt/homebrew/bin/roxy", + "/usr/local/bin/roxy", + "/home/linuxbrew/.linuxbrew/bin/roxy", + ] + .into_iter() + .map(PathBuf::from), + ); + + Ok(candidates + .into_iter() + .find(|candidate| { + candidate + .canonicalize() + .is_ok_and(|resolved| resolved == canonical_current) + }) + .unwrap_or(current)) +} + +/// Validate the privileged installer context and user-owned paths before +/// changing system state. +pub fn validate_install_invocation(config_path: &Path, paths: &RoxyPaths) -> Result<()> { + let user = RuntimeUser::detect()?; + validate_runtime_paths(config_path, paths, &user) +} + +pub fn uninstall() -> Result<()> { + ensure_root()?; + platform_uninstall() +} + +pub fn is_installed() -> bool { + platform_is_installed() +} + +/// Trigger an installed socket-activated service without elevated privileges. +pub fn activate(http_port: u16) -> Result<()> { + TcpStream::connect_timeout( + &SocketAddrV4::new(Ipv4Addr::LOCALHOST, http_port).into(), + std::time::Duration::from_secs(2), + ) + .context("Failed to activate the Roxy service through its HTTP socket")?; + Ok(()) +} + +fn ensure_root() -> Result<()> { + let output = Command::new("id") + .arg("-u") + .output() + .context("Failed to determine current user ID")?; + if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "0" { + bail!("System installation requires root privileges. Run: sudo roxy install"); + } + Ok(()) +} + +fn transfer_runtime_ownership( + config_path: &Path, + paths: &RoxyPaths, + user: &RuntimeUser, +) -> Result<()> { + let mut directories: Vec = vec![paths.data_dir.clone()]; + if let Some(path) = config_path.parent() { + directories.push(path.to_path_buf()); + } + if let Some(path) = paths.pid_file.parent() { + directories.push(path.to_path_buf()); + } + if let Some(path) = paths.log_file.parent() { + directories.push(path.to_path_buf()); + } + if let Some(path) = paths.socket_path.parent() { + directories.push(path.to_path_buf()); + } + directories.sort(); + directories.dedup(); + + let files = [ + config_path.to_path_buf(), + paths.data_dir.join("ca.crt"), + paths.data_dir.join("ca.key"), + paths.pid_file.clone(), + paths.log_file.clone(), + paths.socket_path.clone(), + ]; + validate_targets(directories.iter().chain(files.iter()), &user.home)?; + + for target in directories + .iter() + .chain(files.iter()) + .filter(|path| path.exists()) + { + chown(target, user)?; + } + + Ok(()) +} + +fn validate_runtime_paths(config_path: &Path, paths: &RoxyPaths, user: &RuntimeUser) -> Result<()> { + let targets = [ + config_path.to_path_buf(), + paths.data_dir.clone(), + paths.pid_file.clone(), + paths.log_file.clone(), + paths.socket_path.clone(), + ]; + validate_targets(targets.iter(), &user.home) +} + +fn validate_targets<'a>(targets: impl Iterator, home: &Path) -> Result<()> { + let canonical_home = home.canonicalize().unwrap_or_else(|_| home.to_path_buf()); + for target in targets { + let mut existing = target.as_path(); + while !existing.exists() { + existing = existing.parent().ok_or_else(|| { + anyhow::anyhow!("Cannot resolve Roxy state path {}", target.display()) + })?; + } + let resolves_below_home = existing + .canonicalize() + .is_ok_and(|resolved| resolved.starts_with(&canonical_home)); + if !target.starts_with(home) || target == home || !resolves_below_home { + bail!( + "Unprivileged Roxy state must be stored below {} (got {}). \ + Choose a user-owned --config and [paths] location.", + home.display(), + target.display() + ); + } + } + Ok(()) +} + +fn chown(target: &Path, user: &RuntimeUser) -> Result<()> { + let owner = format!("{}:{}", user.uid, user.gid); + let output = Command::new("chown") + .arg(&owner) + .arg(target) + .output() + .with_context(|| format!("Failed to change ownership of {}", target.display()))?; + if !output.status.success() { + bail!( + "Failed to make {} user-owned: {}", + target.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn platform_install( + executable: &Path, + config_path: &Path, + paths: &RoxyPaths, + daemon: &DaemonConfig, + user: &RuntimeUser, +) -> Result<()> { + macos::install(executable, config_path, paths, daemon, user) +} + +#[cfg(target_os = "linux")] +fn platform_install( + executable: &Path, + config_path: &Path, + paths: &RoxyPaths, + daemon: &DaemonConfig, + user: &RuntimeUser, +) -> Result<()> { + linux::install(executable, config_path, paths, daemon, user) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_install( + _executable: &Path, + _config_path: &Path, + _paths: &RoxyPaths, + _daemon: &DaemonConfig, + _user: &RuntimeUser, +) -> Result<()> { + bail!("Automatic service installation is not supported on this operating system") +} + +#[cfg(target_os = "macos")] +fn platform_uninstall() -> Result<()> { + macos::uninstall() +} + +#[cfg(target_os = "macos")] +fn platform_is_installed() -> bool { + macos::is_installed() +} + +#[cfg(target_os = "linux")] +fn platform_is_installed() -> bool { + linux::is_installed() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_is_installed() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn platform_uninstall() -> Result<()> { + linux::uninstall() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_uninstall() -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_paths_must_remain_below_the_user_home() { + let home = tempfile::tempdir().unwrap(); + let user = RuntimeUser { + name: "dev".into(), + uid: 1000, + gid: 1000, + home: home.path().to_path_buf(), + }; + let paths = RoxyPaths { + data_dir: home.path().join(".local/share/roxy"), + pid_file: home.path().join(".local/state/roxy/run/roxy.pid"), + log_file: home.path().join(".local/state/roxy/roxy.log"), + socket_path: home.path().join(".local/state/roxy/run/roxy.sock"), + }; + + assert!( + validate_runtime_paths(&home.path().join(".config/roxy/config.toml"), &paths, &user) + .is_ok() + ); + assert!(validate_runtime_paths(Path::new("/etc/roxy.toml"), &paths, &user).is_err()); + } +} diff --git a/src/infrastructure/tracing.rs b/src/infrastructure/tracing.rs index f88f420..a267a41 100644 --- a/src/infrastructure/tracing.rs +++ b/src/infrastructure/tracing.rs @@ -12,10 +12,10 @@ pub enum TracingOutput { } /// Initialize tracing based on configuration -/// Priority: ROXY_LOG env > verbose flag > default (info) -pub fn init_tracing(verbose: bool, output: TracingOutput) { +/// Priority: ROXY_LOG env > verbose flag > configured level. +pub fn init_tracing(verbose: bool, configured_level: &str, output: TracingOutput) { let filter = EnvFilter::try_from_env("ROXY_LOG").unwrap_or_else(|_| { - let level = if verbose { "debug" } else { "info" }; + let level = if verbose { "debug" } else { configured_level }; EnvFilter::new(format!("roxy={}", level)) }); diff --git a/src/main.rs b/src/main.rs index e87e70f..1c3bdf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,8 +21,8 @@ use infrastructure::paths::RoxyPaths; #[command(version)] struct Cli { /// Path to the config file - #[arg(short, long, global = true, default_value = "/etc/roxy/config.toml")] - config: PathBuf, + #[arg(short, long, global = true)] + config: Option, /// Enable verbose output #[arg(short, long, global = true)] @@ -169,14 +169,35 @@ enum RouteCommands { /// 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 imported_legacy = !config_store.config_exists() + && config_path == crate::config::default_config_path() + && Path::new("/etc/roxy/config.toml").exists(); + let mut config = if imported_legacy { + let legacy = ConfigStore::new(PathBuf::from("/etc/roxy/config.toml")); + let mut imported = legacy.load()?; + imported.paths = RoxyPaths::default(); + config_store.save(&imported)?; + imported + } else { + config_store.load()? + }; + + // Normalize configs imported by earlier previews of the user-owned layout. + if config_path == crate::config::default_config_path() + && config.paths.data_dir == Path::new("/etc/roxy") + { + config.paths = RoxyPaths::default(); + config_store.save(&config)?; + } let paths = config.paths.clone(); Ok((config, paths)) } fn main() -> Result<()> { let cli = Cli::parse(); - let config_path = &cli.config; + let config_path = cli + .config + .unwrap_or_else(crate::config::default_config_path); // Handle completions before loading config (works even with malformed config) if let Commands::Completions { shell } = &cli.command { @@ -184,11 +205,11 @@ fn main() -> Result<()> { return Ok(()); } - let (config, paths) = load_config_and_paths(config_path)?; - let ctx = AppContext::new(config_path, &paths); + let (config, paths) = load_config_and_paths(&config_path)?; + let ctx = AppContext::new(&config_path, &paths); match cli.command { - Commands::Install => cli::install::execute(&ctx, &paths, &config), + Commands::Install => cli::install::execute(&ctx, &config_path, &paths, &config), Commands::Uninstall { force } => cli::uninstall::execute(force, &ctx, &paths), Commands::Register { domain, @@ -215,18 +236,22 @@ fn main() -> Result<()> { RouteCommands::List { wildcard, domain } => cli::route::list(domain, wildcard, &ctx), }, Commands::List => cli::list::execute(&ctx), - Commands::Start { foreground } => { - cli::start::execute(foreground, cli.verbose, config_path, &paths, &config.daemon) - } + Commands::Start { foreground } => cli::start::execute( + foreground, + cli.verbose, + &config_path, + &paths, + &config.daemon, + ), Commands::Stop => cli::stop::execute(&ctx), - Commands::Restart => cli::restart::execute(cli.verbose, config_path, &ctx), + Commands::Restart => cli::restart::execute(cli.verbose, &config_path, &ctx), Commands::Status => cli::status::execute(&ctx, &config.daemon), Commands::Logs { lines, clear, follow, } => cli::logs::execute(lines, clear, follow, &paths), - Commands::Reload => cli::reload::execute(cli.verbose, config_path, &ctx), + Commands::Reload => cli::reload::execute(&ctx), Commands::Completions { .. } => unreachable!(), } }