Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ jobs:
enabled = true
EOF

- name: Check root low-port conflicts against a separate process
timeout-minutes: 10
shell: bash
run: |
set -euo pipefail
cargo build --locked --all-features -p daemon --example pv-fake
cargo nextest list --locked --all-features -p platform --lib --run-ignored only \
-E 'test(=helper::tests::pf_apply_rejects_a_low_port_conflict_as_root)' \
--message-format json > "$RUNNER_TEMP/low-port-tests.json"
test_binary=$(python3 - "$RUNNER_TEMP/low-port-tests.json" <<'PY'
import json
import sys
with open(sys.argv[1]) as source:
tests = json.load(source)
suite = tests["rust-suites"]["platform"]
selected = [name for name, case in suite["testcases"].items()
if case["filter-match"]["status"] == "matches"]
assert selected == ["helper::tests::pf_apply_rejects_a_low_port_conflict_as_root"], selected
assert suite["testcases"][selected[0]]["ignored"]
print(suite["binary-path"])
PY
)
sudo -n "$test_binary" --ignored --exact helper::tests::pf_apply_rejects_a_low_port_conflict_as_root

- name: Run tests
id: tests
run: cargo nextest run --profile ci --user-config-file "$RUNNER_TEMP/nextest-user.toml" --workspace --all-features --locked
Expand Down Expand Up @@ -160,7 +184,6 @@ jobs:
cargo test --locked -p state --lib update_lock
cargo test --locked -p resources --lib target_platform_for
cargo test --locked -p platform --test unsupported_launch_agent
cargo test --locked -p platform --test unsupported_listener
cargo test --locked -p daemon --test platform_preflight

- name: Compile daemon tests on Linux
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the

macOS 13 may continue to run PV when the application and Managed Resource binaries remain compatible, but it is untested and unsupported. Dropping support does not by itself require raising binary deployment targets or republishing otherwise compatible Managed Resource artifacts. Before PV deliberately ships an application binary that cannot run on macOS 13, the application update manifest and updater must prevent an incompatible update from being activated there.

The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Private-interface acceptance tests such as listener inspection run as part of every matrix lane. New supported macOS major versions must be added to the matrix before PV relies on behavior there.
The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Every use of an undocumented or private macOS interface fails closed: a result that cannot prove it saw other processes is an error, never an empty success. Acceptance tests for OS behavior PV relies on, such as bind conflict rules and process identity, observe a separate process and run in every matrix lane. Behavior that depends on root is tested as root. New supported macOS major versions must be added to the matrix before PV relies on behavior there.

Linux and Windows are committed subsequent platforms. During macOS stabilization, the installed application and runtime crates compile natively on macOS, Linux, and Windows so new system boundaries do not create unnecessary portability blockers.

Expand Down Expand Up @@ -80,7 +80,7 @@ Managed Resources remain external binaries/artifacts managed by PV rather than R

Initial PV distribution is a standalone install script/direct binary download. Homebrew support can be added after the release flow stabilizes. A signed `.pkg` is deferred unless macOS trust/onboarding requires it.

The install script downloads the PV application and its separate privileged helper artifact, verifies each against its published SHA-256 checksum, and installs both plus the required adjacent `pv-helper.json` release metadata into the user-owned release directory. Setup and update require that metadata rather than relying on a checksum compiled into the application. If either verification fails, installation deletes the bad download and stops before editing shell profiles or running setup. Dogfood helper artifacts use ad-hoc code signing; Developer ID signing, notarization, and a signed package are deferred until public release.
The install script downloads the PV application and its separate privileged helper artifact, verifies each against its published SHA-256 checksum, and installs both plus the required adjacent `pv-helper.json` release metadata into the user-owned release directory. Setup and update require that metadata rather than relying on a checksum compiled into the application. If either verification fails, installation deletes the bad download and stops before editing shell profiles or running setup. Dogfood helper artifacts use ad-hoc code signing; Developer ID signing, notarization, and a signed package are deferred until public release. No correctness path may depend on the signing identity of PV or of any ancestor process.

The stable installer URL serves a generated installer script based on PV app release metadata. The bash installer does not need to parse the JSON PV app update manifest. The installer script may embed or otherwise receive the resolved current PV version, platform asset URLs, and SHA-256 checksums from the server-side installer generation flow. The JSON PV app update manifest is used by the Rust self-updater.

Expand Down Expand Up @@ -162,7 +162,7 @@ The Gateway listens as the user on high loopback ports only. PV prefers uncommon

PV v1 does not expose Projects on the LAN or through tunnels. LAN access or tunnel integrations such as Cloudflare Tunnels may be considered later.

If another process is already listening on loopback port `80` or `443`, `pv setup` and `pv ports:install` fail with a clear conflict instead of silently taking over traffic. When detectable, PV reports the process that owns the port.
Before installing redirects, `pv setup` and `pv ports:install` ask the privileged helper whether loopback ports `80` and `443` are free, and the helper checks again before applying pf rules. The helper decides with a root bind probe on `127.0.0.1` and `0.0.0.0` that ignores closing connections. If either port is unavailable, PV fails with a clear conflict instead of silently taking over traffic. `lsof` supplies listener names and pids as diagnostic information only: its wildcard address does not distinguish an IPv6-only listener from a dual-stack listener, so it cannot prove which process caused an IPv4 bind failure.

### PF Health and Recovery

Expand Down Expand Up @@ -310,13 +310,13 @@ Privileged repair happens only from foreground commands such as `pv setup`, `pv

PV uses a separate minimal `pv-helper` executable registered as the system launchd job `com.prvious.pv.helper`. Launchd creates the restricted `/var/run/com.prvious.pv.helper.sock` and activates the helper on demand. Once activated, the helper serves sequential connections until launchd stops it, avoiding launchd restart throttling between related operations. Its LaunchDaemon writes startup and uncaptured child-process diagnostics to `/var/log/com.prvious.pv.helper.err.log`. The daemon, Gateway, DNS resolver, and all Managed Resources remain unprivileged. The helper supports one installing macOS account per machine. Helper removal is restricted to the original installing UID, so that account must remove it before setup by a replacement account; an unavailable owner requires manual administrator recovery.

The helper accepts only versioned typed requests for status, DNS inspection/apply/removal, PF inspection/apply/reload/removal, and CA trust inspection/apply/removal. Requests cannot provide commands, executable paths, destination paths, raw configuration, environment behavior, or network operations. DNS and PF content is generated internally for fixed system destinations. `CaApply` reads only the installing account's fixed PV CA path, validates PV CA metadata and the requested fingerprint, stages that exact certificate in the fixed root work path, revalidates it, and trusts it. The helper revalidates ownership, conflicts, arguments, and expected state before each mutation. `pv.db` remains the only desired-state source of truth; root-owned helper metadata contains only the installing UID, helper version, and protocol version.
The helper accepts only versioned typed requests for status, DNS inspection/apply/removal, PF inspection/apply/removal, loopback port 80/443 inspection, and CA trust inspection/apply/removal. Requests cannot provide commands, executable paths, destination paths, raw configuration, environment behavior, or network operations. DNS and PF content is generated internally for fixed system destinations. `CaApply` reads only the installing account's fixed PV CA path, validates PV CA metadata and the requested fingerprint, stages that exact certificate in the fixed root work path, revalidates it, and trusts it. The helper revalidates ownership, conflicts, arguments, and expected state before each mutation. `pv.db` remains the only desired-state source of truth; root-owned helper metadata contains only the installing UID, helper version, and protocol version.

The socket is restricted to the installing account, and the helper verifies the Unix peer UID before decoding and dispatching a bounded request. Protocol version is validated independently from app and helper versions. Install and replacement readiness uses a small protocol-neutral lifecycle probe, separate from the versioned operational request schema, so an app may verify a newly installed helper before activating a matching protocol update. Missing or incompatible helpers produce repair guidance to run `pv setup`; `pv doctor` reports cross-account authentication failures with guidance to use the original installing account or perform manual administrator recovery. Normal DNS, PF, and CA commands never fall back to `sudo`.

`sudo` is used only by foreground helper lifecycle operations: initial install, replacement, repair, and removal. Initial setup therefore requires one administrator authentication, while later typed DNS, PF, and CA repairs continue without new prompts after sudo timestamp expiry or reboot. The helper installation path verifies the candidate checksum and ad-hoc code signature before replacing the root-owned executable and launchd registration.

Helper install, replacement, and removal hold a persistent-file advisory lock under `/Library/PrivilegedHelperTools` for the full root-owned lifecycle transaction. This machine-wide lock serializes the fixed candidate, rollback, executable, metadata, plist, and launchd paths across macOS accounts. Setup, update, and uninstall also hold a persistent per-account lock at `~/.pv-helper-lifecycle.lock` across helper-dependent integration and state changes; keeping it outside `~/.pv` lets `pv uninstall --prune` remain serialized while removing that tree.
Helper install, replacement, and removal hold a persistent-file advisory lock under `/Library/PrivilegedHelperTools` for the full root-owned lifecycle transaction. This machine-wide lock serializes the fixed candidate, rollback, executable, metadata, plist, and launchd paths across macOS accounts. After registering or restoring the launchd job, the lifecycle operation waits up to five seconds for its control socket to become available before failing. Authentication and invalid-response errors fail immediately. Setup, update, and uninstall also hold a persistent per-account lock at `~/.pv-helper-lifecycle.lock` across helper-dependent integration and state changes; keeping it outside `~/.pv` lets `pv uninstall --prune` remain serialized while removing that tree.

`pv doctor` remains unprivileged and read-only. It reports helper availability, helper version, and helper protocol, uses narrowly scoped helper inspections when direct DNS, PF, or CA inspection is blocked, and continues reporting all other checks when the helper is unavailable.

Expand Down Expand Up @@ -944,7 +944,7 @@ Managed Resource artifacts must run from PV's installed resource layout before p

PV does not rely on whole-archive binary or string scanning as a v1 publication gate. Artifact recipes prove portability through archive layout validation, adapter-required file checks, and target-platform smoke tests. Resource-specific static checks may be added later if a resource's packaging risk justifies them.

For v1, PV ad-hoc signs Managed Resource Mach-O binaries in the release pipeline after any binary path fixes. Paid Developer ID signing and notarization for Managed Resource artifacts are deferred unless macOS Gatekeeper or quarantine behavior requires them for a reliable v1 install experience. Checksums are computed only after final signing and packaging.
For v1, PV ad-hoc signs Managed Resource Mach-O binaries in the release pipeline after any binary path fixes. Paid Developer ID signing and notarization for Managed Resource artifacts are deferred unless macOS Gatekeeper or quarantine behavior requires them for a reliable v1 install experience. Checksums are computed only after final signing and packaging. No correctness path may depend on the signing identity of PV or of any ancestor process.

The remote Managed Resource artifact manifest is the only manifest in v1. PV-owned artifact archives do not contain per-archive manifest files in v1; validation comes from the remote manifest plus the compiled-in resource adapter rules.

Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/commands/artifact_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,12 @@ mod tests {
}

impl Environment for TestEnvironment {
fn inspect_low_ports(
&self,
) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, _key: &str) -> Option<OsString> {
None
}
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,12 @@ mod tests {
}

impl Environment for AccessTrackingEnvironment {
fn inspect_low_ports(
&self,
) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, _key: &str) -> Option<OsString> {
self.record_access();
None
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/commands/php.rs
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,12 @@ mod tests {
struct UnsupportedPlatformEnvironment;

impl Environment for UnsupportedPlatformEnvironment {
fn inspect_low_ports(
&self,
) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, _key: &str) -> Option<OsString> {
None
}
Expand Down
35 changes: 10 additions & 25 deletions crates/cli/src/commands/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ use crate::output::{Line, Mark, Output, Streams};

use super::pf_diagnostics::PfRoutingDiagnostic;

const LOW_PORTS: [u16; 2] = [80, 443];

pub(crate) fn status(
args: PortsStatusArgs,
environment: &impl Environment,
Expand Down Expand Up @@ -78,22 +76,21 @@ pub(crate) fn install(
streams: &mut Streams<'_>,
) -> Result<ExitCode, ExecuteError> {
let paths = pv_paths(environment)?;
let listening_ports = environment.loopback_tcp_listener_ports()?;
let low_port_conflicts = low_port_conflicts(&listening_ports);
let inspection = environment.inspect_low_ports()?;
let output = &mut streams.out;

if !low_port_conflicts.is_empty() {
if inspection.ports.iter().any(|port| !port.available) {
output.failure("Port redirect preparation failed")?;
for port in &low_port_conflicts {
output.detail(format!("Loopback TCP port {port} already has a listener."))?;
for port in inspection.ports.iter().filter(|port| !port.available) {
output.detail(port.conflict_message())?;
if port.owners.is_empty() {
output.hint(
"find it",
&format!("sudo lsof -nP -iTCP:{} -sTCP:LISTEN", port.port),
)?;
}
}
output.detail("Stop the conflicting service, then run `pv ports:install` again.")?;
if output.surface().decorated()
&& let Some(port) = low_port_conflicts.first()
{
output.hint("find it", &format!("lsof -nP -iTCP:{port} -sTCP:LISTEN"))?;
}

return Ok(ExitCode::FAILURE);
}

Expand Down Expand Up @@ -350,18 +347,6 @@ pub(crate) fn uninstall(
Ok(ExitCode::SUCCESS)
}

fn low_port_conflicts(listening_ports: &std::collections::BTreeSet<u16>) -> Vec<u16> {
let mut conflicts = Vec::new();

for port in LOW_PORTS {
if listening_ports.contains(&port) {
conflicts.push(port);
}
}

conflicts
}

fn pf_config_from_assignments(assignments: &GatewayPortAssignments) -> PfRedirectConfig {
PfRedirectConfig::new(assignments.http.port, assignments.https.port)
}
Expand Down
16 changes: 11 additions & 5 deletions crates/cli/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,7 @@ pub trait Environment {
platform::loopback_tcp_port_available(port)
}

fn loopback_tcp_listener_ports(
&self,
) -> Result<std::collections::BTreeSet<u16>, platform::PlatformError> {
platform::loopback_tcp_listener_ports()
}
fn inspect_low_ports(&self) -> Result<platform::LowPortInspection, platform::PlatformError>;

fn install_pf_redirects(
&self,
Expand Down Expand Up @@ -276,6 +272,10 @@ pub(crate) fn app_update_manifest_url(environment: &impl Environment) -> String
pub struct ProcessEnvironment;

impl Environment for ProcessEnvironment {
fn inspect_low_ports(&self) -> Result<platform::LowPortInspection, platform::PlatformError> {
platform::PrivilegedHelperClient.inspect_low_ports()
}

fn var_os(&self, key: &str) -> Option<OsString> {
process_var_os(key)
}
Expand Down Expand Up @@ -402,6 +402,12 @@ mod tests {
}

impl Environment for TestEnvironment {
fn inspect_low_ports(
&self,
) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, key: &str) -> Option<OsString> {
self.vars.get(key).cloned()
}
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/tests/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ impl TestEnvironment {
}

impl Environment for TestEnvironment {
fn inspect_low_ports(&self) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, _key: &str) -> Option<OsString> {
None
}
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/tests/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ fn composer_exec_env(home: &Utf8Path, php_track: &str) -> anyhow::Result<Vec<(St
}

impl Environment for TestEnvironment {
fn inspect_low_ports(&self) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, key: &str) -> Option<OsString> {
self.vars.borrow().get(key).cloned()
}
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/tests/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ enum BootoutError {
}

impl Environment for TestEnvironment {
fn inspect_low_ports(&self) -> Result<platform::LowPortInspection, platform::PlatformError> {
Err(platform::PlatformError::PrivilegedHelperUnavailable)
}

fn var_os(&self, _key: &str) -> Option<OsString> {
None
}
Expand Down
Loading
Loading