diff --git a/Cargo.lock b/Cargo.lock index 0c9132a5..ccf5d170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "sha2 0.10.9", "state", "thiserror", + "time", "tokio", "yaml_serde", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3badec15..766aaa9f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -27,6 +27,7 @@ sha2 = { workspace = true } self-update = { path = "../self-update" } state = { path = "../state" } thiserror = { workspace = true } +time = { workspace = true } tokio = { workspace = true } yaml_serde = { workspace = true } diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 72d2544d..ca6b46dc 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -77,7 +77,7 @@ pub(crate) enum Command { DnsUninstall, #[command(name = "ports:status", about = "Show PV pf redirect status")] - PortsStatus, + PortsStatus(PortsStatusArgs), #[command(name = "ports:install", about = "Install or repair PV pf redirects")] PortsInstall, @@ -509,6 +509,12 @@ pub(crate) struct StatusArgs { pub(crate) json: bool, } +#[derive(Debug, clap::Args)] +pub(crate) struct PortsStatusArgs { + #[arg(long, help = "Print pf redirect status as JSON")] + pub(crate) json: bool, +} + #[derive(Debug, clap::Args)] pub(crate) struct LogsArgs { #[arg( @@ -552,7 +558,10 @@ pub(crate) struct LogsArgs { } #[derive(Debug, clap::Args)] -pub(crate) struct DoctorArgs {} +pub(crate) struct DoctorArgs { + #[arg(long, help = "Print diagnostics as JSON")] + pub(crate) json: bool, +} #[derive(Debug, clap::Args)] pub(crate) struct JobsArgs { diff --git a/crates/cli/src/commands/doctor.rs b/crates/cli/src/commands/doctor.rs index a1104159..70bde5fe 100644 --- a/crates/cli/src/commands/doctor.rs +++ b/crates/cli/src/commands/doctor.rs @@ -3,9 +3,10 @@ use std::process::ExitCode; use camino::Utf8PathBuf; use platform::{ - CaFileState, LaunchAgentFileState, LocalCaMetadata, PfConfReference, PfFileState, - PfRedirectConfig, ResolverConfig, ResolverFileState, TrustDomainState, + CaFileState, LaunchAgentFileState, LocalCaMetadata, ResolverConfig, ResolverFileState, + TrustDomainState, }; +use serde::Serialize; use state::{Database, JobStatus, PvPaths, RuntimeObservedStatus, StateError}; use crate::args::DoctorArgs; @@ -14,8 +15,10 @@ use crate::error::CliError; use crate::error::ExecuteError; use crate::output::{Output, OutputMode}; +use super::pf_diagnostics::{PfRoutingDiagnostic, PfRoutingState}; + pub(crate) fn run( - _args: DoctorArgs, + args: DoctorArgs, environment: &impl Environment, stdout: &mut impl Write, ) -> Result { @@ -25,12 +28,19 @@ pub(crate) fn run( } else { ExitCode::SUCCESS }; + if args.json { + serde_json::to_writer(&mut *stdout, &report)?; + writeln!(stdout)?; + + return Ok(exit_code); + } let mut output = Output::new(stdout, OutputMode::plain()); report.write_plain(&mut output)?; Ok(exit_code) } +#[derive(Serialize)] struct DoctorReport { checks: Vec, } @@ -47,7 +57,7 @@ impl DoctorReport { launch_agent_check(&launch_agent), daemon_socket_check(&paths, &launch_agent), dns_check(environment, &paths)?, - ports_check(environment, &paths)?, + ports_check(environment, &paths, database.as_ref())?, ca_check(environment, &paths), recent_jobs_check(database.as_ref())?, runtime_states_check(database.as_ref())?, @@ -104,7 +114,8 @@ impl DoctorReport { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] enum CheckStatus { Pass, Warn, @@ -121,12 +132,15 @@ impl CheckStatus { } } +#[derive(Serialize)] struct DoctorCheck { status: CheckStatus, name: &'static str, message: String, detail: Option, repair: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + routing: Option, } impl DoctorCheck { @@ -137,6 +151,7 @@ impl DoctorCheck { message: message.into(), detail: None, repair: None, + routing: None, } } @@ -147,6 +162,7 @@ impl DoctorCheck { message: message.into(), detail: None, repair, + routing: None, } } @@ -157,6 +173,7 @@ impl DoctorCheck { message: message.into(), detail: None, repair: Some(repair), + routing: None, } } @@ -164,6 +181,11 @@ impl DoctorCheck { self.detail = Some(detail.into()); self } + + fn with_routing(mut self, routing: PfRoutingDiagnostic) -> Self { + self.routing = Some(routing); + self + } } fn layout_check(paths: &PvPaths) -> DoctorCheck { @@ -338,95 +360,35 @@ fn dns_check(environment: &impl Environment, paths: &PvPaths) -> Result, ) -> Result { - let prepared_anchor = platform::inspect_pf_anchor_file(&paths.pf_anchor_config(), None); - let prepared_reference = - platform::inspect_pf_conf_reference(&paths.pf_conf_reference_config(), None); - if let Some(check) = pf_file_failure( - "Port redirect config", - "prepared pf config", - &prepared_anchor, - &prepared_reference, - ) { - return Ok(check); - } - - let expected_anchor = pf_config_from_anchor_state(&prepared_anchor); - let expected_reference = pf_reference_from_state(&prepared_reference); - let system_anchor_path = pf_anchor_path(environment)?; - let system_pf_conf_path = pf_conf_path(environment)?; - let system_anchor = - platform::inspect_pf_anchor_file(&system_anchor_path, expected_anchor.as_ref()); - let system_reference = - platform::inspect_pf_conf_reference(&system_pf_conf_path, expected_reference.as_ref()); - if let Some(check) = pf_file_failure( - "Port redirect config", - "system pf config", - &system_anchor, - &system_reference, - ) { - return Ok(check); - } - - let active = match environment.active_pf_redirect_config() { - Ok(active) => active, - Err(error) => { - return Ok(DoctorCheck::fail( - "Port redirect config", - "active pf redirects could not be inspected", - "pv ports:install", - ) - .with_detail(error.to_string())); - } + let routing = PfRoutingDiagnostic::read(environment, paths, database)?; + let message = match routing.state { + PfRoutingState::Active => "low-port routing is active", + PfRoutingState::Inactive => "low-port redirects are inactive", + PfRoutingState::Drifted => "low-port routing has drifted", + PfRoutingState::Unknown => "low-port routing could not be verified", + }; + let detail = format!( + "evidence: {}; expected: HTTP {}, HTTPS {}; active: HTTP {}, HTTPS {}; observed: {}", + routing.evidence.as_str(), + display_port(routing.expected_http_port), + display_port(routing.expected_https_port), + display_port(routing.active_http_port), + display_port(routing.active_https_port), + routing.observed_at, + ); + let check = if routing.is_active() { + DoctorCheck::pass("Port redirect config", message) + } else { + DoctorCheck::fail("Port redirect config", message, "pv ports:install") }; - if active.as_ref() == expected_anchor.as_ref() { - return Ok(DoctorCheck::pass( - "Port redirect config", - "system pf config and active redirects are current", - )); - } - Ok(DoctorCheck::fail( - "Port redirect config", - "active pf redirects are not loaded", - "pv ports:install", - )) + Ok(check.with_detail(detail).with_routing(routing)) } -fn pf_file_failure( - name: &'static str, - label: &'static str, - anchor: &PfFileState, - reference: &PfFileState, -) -> Option { - match (anchor, reference) { - (PfFileState::Current { .. }, PfFileState::Current { .. }) => None, - (PfFileState::Missing { path }, _) | (_, PfFileState::Missing { path }) => Some( - DoctorCheck::fail(name, format!("{label} is missing"), "pv ports:install") - .with_detail(format!("path: {path}")), - ), - (PfFileState::Conflict { path }, _) | (_, PfFileState::Conflict { path }) => Some( - DoctorCheck::fail(name, format!("{label} is not PV-owned"), "pv ports:install") - .with_detail(format!("path: {path}")), - ), - (PfFileState::Unreadable { path, message }, _) - | (_, PfFileState::Unreadable { path, message }) => Some( - DoctorCheck::fail( - name, - format!("{label} could not be inspected"), - "pv ports:install", - ) - .with_detail(format!("{path}: {message}")), - ), - (PfFileState::Stale { path, .. }, _) | (_, PfFileState::Stale { path, .. }) => Some( - DoctorCheck::fail( - name, - format!("{label} is PV-owned but stale"), - "pv ports:install", - ) - .with_detail(format!("path: {path}")), - ), - } +fn display_port(port: Option) -> String { + port.map_or_else(|| "-".to_owned(), |port| port.to_string()) } fn ca_check(environment: &impl Environment, paths: &PvPaths) -> DoctorCheck { @@ -649,26 +611,6 @@ fn resolver_config_from_state(state: &ResolverFileState) -> Option) -> Option { - match state { - PfFileState::Current { value, .. } => Some(value.clone()), - PfFileState::Missing { .. } - | PfFileState::Stale { .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - -fn pf_reference_from_state(state: &PfFileState) -> Option { - match state { - PfFileState::Current { value, .. } => Some(*value), - PfFileState::Missing { .. } - | PfFileState::Stale { .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - fn metadata_from_local_ca(state: &CaFileState) -> Option { match state { CaFileState::Current { metadata, .. } => Some(metadata.clone()), @@ -714,13 +656,3 @@ fn resolver_test_path(environment: &impl Environment) -> Result Result { - Utf8PathBuf::from_path_buf(environment.pf_anchor_path()) - .map_err(|path| CliError::NonUtf8Path { path }.into()) -} - -fn pf_conf_path(environment: &impl Environment) -> Result { - Utf8PathBuf::from_path_buf(environment.pf_conf_path()) - .map_err(|path| CliError::NonUtf8Path { path }.into()) -} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 3857eefa..fa6c1a45 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -23,6 +23,7 @@ mod jobs; mod logs; mod mailpit; mod mysql; +mod pf_diagnostics; mod php; mod ports; mod postgres; @@ -69,7 +70,7 @@ where Command::DnsStatus => dns::status(environment, stdout), Command::DnsInstall => dns::install(environment, stdout), Command::DnsUninstall => dns::uninstall(environment, stdout), - Command::PortsStatus => ports::status(environment, stdout), + Command::PortsStatus(args) => ports::status(args, environment, stdout), Command::PortsInstall => ports::install(environment, stdout), Command::PortsUninstall => ports::uninstall(environment, stdout), Command::CaStatus => ca::status(environment, stdout), @@ -165,7 +166,7 @@ fn required_capability(command: &Command) -> Option { Command::DaemonRun | Command::Link(_) | Command::Unlink(_) => { Some(PlatformCapability::DaemonIpc) } - Command::PortsStatus | Command::PortsInstall | Command::PortsUninstall => { + Command::PortsStatus(_) | Command::PortsInstall | Command::PortsUninstall => { Some(PlatformCapability::LowPortFrontend) } Command::CaStatus | Command::CaTrust | Command::CaUntrust => { @@ -395,7 +396,7 @@ mod tests { Command::DaemonDisable, Command::DaemonRestart, Command::Status(StatusArgs { json: false }), - Command::Doctor(DoctorArgs {}), + Command::Doctor(DoctorArgs { json: false }), Command::Update(UpdateArgs { check: false, json: false, @@ -432,7 +433,7 @@ mod tests { fn required_capability_maps_low_port_frontend_commands() { assert_required_capability( &[ - Command::PortsStatus, + Command::PortsStatus(crate::args::PortsStatusArgs { json: false }), Command::PortsInstall, Command::PortsUninstall, ], diff --git a/crates/cli/src/commands/pf_diagnostics.rs b/crates/cli/src/commands/pf_diagnostics.rs new file mode 100644 index 00000000..5137d783 --- /dev/null +++ b/crates/cli/src/commands/pf_diagnostics.rs @@ -0,0 +1,291 @@ +use camino::Utf8PathBuf; +use platform::{ActivePfRedirectInspection, PfConfReference, PfFileState, PfRedirectConfig}; +use serde::Serialize; +use state::{Database, GatewayPort, PortOwner, PvPaths}; +use time::OffsetDateTime; + +use crate::environment::Environment; +use crate::error::{CliError, ExecuteError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum PfRoutingState { + Active, + Inactive, + Drifted, + Unknown, +} + +impl PfRoutingState { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + Self::Drifted => "drifted", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum PfRoutingEvidence { + Pfctl, + Probe, + Unavailable, +} + +impl PfRoutingEvidence { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Pfctl => "pfctl", + Self::Probe => "probe", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct PfRoutingDiagnostic { + pub(super) state: PfRoutingState, + pub(super) evidence: PfRoutingEvidence, + pub(super) expected_http_port: Option, + pub(super) expected_https_port: Option, + pub(super) active_http_port: Option, + pub(super) active_https_port: Option, + pub(super) observed_at: String, +} + +impl PfRoutingDiagnostic { + pub(super) fn read( + environment: &impl Environment, + paths: &PvPaths, + database: Option<&Database>, + ) -> Result { + let expected = expected_redirect_config(database)?; + let prepared_anchor = + platform::inspect_pf_anchor_file(&paths.pf_anchor_config(), expected.as_ref()); + let expected_reference = PfConfReference; + let prepared_reference = platform::inspect_pf_conf_reference( + &paths.pf_conf_reference_config(), + Some(&expected_reference), + ); + let system_anchor_path = utf8_path(environment.pf_anchor_path())?; + let system_reference_path = utf8_path(environment.pf_conf_path())?; + let system_anchor = + platform::inspect_pf_anchor_file(&system_anchor_path, expected.as_ref()); + let system_reference = + platform::inspect_pf_conf_reference(&system_reference_path, Some(&expected_reference)); + let files_current = matches!(prepared_anchor, PfFileState::Current { .. }) + && matches!(prepared_reference, PfFileState::Current { .. }) + && matches!(system_anchor, PfFileState::Current { .. }) + && matches!(system_reference, PfFileState::Current { .. }); + + let (state, evidence, active_http_port, active_https_port) = + match environment.inspect_active_pf_redirects_unprivileged() { + Ok(inspection) => classify_pfctl(expected.as_ref(), files_current, &inspection), + Err(_) => classify_probe(environment, paths, expected.as_ref(), files_current), + }; + + Ok(Self { + state, + evidence, + expected_http_port: expected.as_ref().map(|config| config.http_port), + expected_https_port: expected.as_ref().map(|config| config.https_port), + active_http_port, + active_https_port, + observed_at: timestamp(), + }) + } + + pub(super) const fn is_active(&self) -> bool { + matches!(self.state, PfRoutingState::Active) + } +} + +fn classify_pfctl( + expected: Option<&PfRedirectConfig>, + files_current: bool, + inspection: &ActivePfRedirectInspection, +) -> (PfRoutingState, PfRoutingEvidence, Option, Option) { + let active_http_port = inspection.pv_config.as_ref().map(|config| config.http_port); + let active_https_port = inspection + .pv_config + .as_ref() + .map(|config| config.https_port); + + if inspection.pv_config.as_ref() == expected && expected.is_some() { + let state = if files_current { + PfRoutingState::Active + } else { + PfRoutingState::Drifted + }; + + return ( + state, + PfRoutingEvidence::Pfctl, + active_http_port, + active_https_port, + ); + } + + if inspection.pv_config.is_some() { + return ( + PfRoutingState::Drifted, + PfRoutingEvidence::Pfctl, + active_http_port, + active_https_port, + ); + } + + let Some(expected) = expected else { + return ( + PfRoutingState::Inactive, + PfRoutingEvidence::Pfctl, + None, + None, + ); + }; + let active_http_port = inspection + .loopback_target_ports + .contains(&expected.http_port) + .then_some(expected.http_port); + let active_https_port = inspection + .loopback_target_ports + .contains(&expected.https_port) + .then_some(expected.https_port); + let state = if active_http_port.is_some() || active_https_port.is_some() { + PfRoutingState::Drifted + } else { + PfRoutingState::Inactive + }; + + ( + state, + PfRoutingEvidence::Pfctl, + active_http_port, + active_https_port, + ) +} + +fn classify_probe( + environment: &impl Environment, + paths: &PvPaths, + expected: Option<&PfRedirectConfig>, + files_current: bool, +) -> (PfRoutingState, PfRoutingEvidence, Option, Option) { + if let Some(expected) = expected + && environment + .probe_gateway_redirects(expected, &paths.ca_certificate()) + .is_ok() + { + let state = if files_current { + PfRoutingState::Active + } else { + PfRoutingState::Drifted + }; + + return ( + state, + PfRoutingEvidence::Probe, + Some(expected.http_port), + Some(expected.https_port), + ); + } + + let state = if files_current { + PfRoutingState::Unknown + } else { + PfRoutingState::Drifted + }; + + (state, PfRoutingEvidence::Unavailable, None, None) +} + +fn expected_redirect_config( + database: Option<&Database>, +) -> Result, ExecuteError> { + let Some(database) = database else { + return Ok(None); + }; + let assignments = database.assigned_ports()?; + let http_port = assignments.iter().find_map(|assignment| { + (assignment.owner == PortOwner::Gateway(GatewayPort::Http)).then_some(assignment.port) + }); + let https_port = assignments.iter().find_map(|assignment| { + (assignment.owner == PortOwner::Gateway(GatewayPort::Https)).then_some(assignment.port) + }); + + Ok(http_port + .zip(https_port) + .map(|(http_port, https_port)| PfRedirectConfig::new(http_port, https_port))) +} + +fn utf8_path(path: std::path::PathBuf) -> Result { + Utf8PathBuf::from_path_buf(path).map_err(|path| CliError::NonUtf8Path { path }.into()) +} + +fn timestamp() -> String { + let now = OffsetDateTime::now_utc(); + let month: u8 = now.month().into(); + + format!( + "{:04}-{month:02}-{:02}T{:02}:{:02}:{:02}Z", + now.year(), + now.day(), + now.hour(), + now.minute(), + now.second() + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn readable_rules_classify_exact_absent_and_partial_redirects() { + let expected = PfRedirectConfig::new(48080, 48443); + let exact = ActivePfRedirectInspection { + pv_config: Some(expected.clone()), + loopback_target_ports: BTreeSet::from([48080, 48443]), + }; + let absent = ActivePfRedirectInspection { + pv_config: None, + loopback_target_ports: BTreeSet::new(), + }; + let partial = ActivePfRedirectInspection { + pv_config: None, + loopback_target_ports: BTreeSet::from([48080]), + }; + + assert_eq!( + classify_pfctl(Some(&expected), true, &exact).0, + PfRoutingState::Active + ); + assert_eq!( + classify_pfctl(Some(&expected), true, &absent).0, + PfRoutingState::Inactive + ); + assert_eq!( + classify_pfctl(Some(&expected), true, &partial).0, + PfRoutingState::Drifted + ); + } + + #[test] + fn exact_rules_with_stale_files_are_drifted() { + let expected = PfRedirectConfig::new(48080, 48443); + let inspection = ActivePfRedirectInspection { + pv_config: Some(expected.clone()), + loopback_target_ports: BTreeSet::from([48080, 48443]), + }; + + assert_eq!( + classify_pfctl(Some(&expected), false, &inspection).0, + PfRoutingState::Drifted + ); + } +} diff --git a/crates/cli/src/commands/ports.rs b/crates/cli/src/commands/ports.rs index 8662cf8b..809b9de7 100644 --- a/crates/cli/src/commands/ports.rs +++ b/crates/cli/src/commands/ports.rs @@ -6,47 +6,61 @@ use camino::{Utf8Path, Utf8PathBuf}; use platform::{PfConfReference, PfFileState, PfRedirectConfig}; use state::{Database, GatewayPort, GatewayPortAssignments, PortOwner, PvPaths, StateError}; +use crate::args::PortsStatusArgs; use crate::environment::Environment; use crate::error::{CliError, ExecuteError}; use crate::output::{Output, OutputMode}; +use super::pf_diagnostics::PfRoutingDiagnostic; + const LOW_PORTS: [u16; 2] = [80, 443]; pub(crate) fn status( + args: PortsStatusArgs, environment: &impl Environment, stdout: &mut impl Write, ) -> Result { let paths = pv_paths(environment)?; - let prepared_anchor_path = paths.pf_anchor_config(); - let prepared_reference_path = paths.pf_conf_reference_config(); - let system_anchor_path = pf_anchor_path(environment)?; - let system_pf_conf_path = pf_conf_path(environment)?; - let prepared_anchor_state = platform::inspect_pf_anchor_file(&prepared_anchor_path, None); - let prepared_reference_state = - platform::inspect_pf_conf_reference(&prepared_reference_path, None); - let expected_anchor = pf_config_from_anchor_state(&prepared_anchor_state); - let expected_reference = pf_reference_from_state(&prepared_reference_state); - let system_anchor_state = - platform::inspect_pf_anchor_file(&system_anchor_path, expected_anchor.as_ref()); - let system_reference_state = - platform::inspect_pf_conf_reference(&system_pf_conf_path, expected_reference.as_ref()); + let database = Database::open_read_only(&paths)?; + let diagnostic = PfRoutingDiagnostic::read(environment, &paths, database.as_ref())?; + let exit_code = if diagnostic.is_active() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }; + + if args.json { + serde_json::to_writer(&mut *stdout, &diagnostic)?; + writeln!(stdout)?; + + return Ok(exit_code); + } + let mut output = Output::new(stdout, OutputMode::plain()); output.line("Port redirect status")?; - write_pf_anchor_state(&mut output, "Prepared pf anchor", &prepared_anchor_state)?; - write_pf_reference_state( - &mut output, - "Prepared pf.conf reference", - &prepared_reference_state, - )?; - write_pf_anchor_state(&mut output, "System pf anchor", &system_anchor_state)?; - write_pf_reference_state( - &mut output, - "System pf.conf reference", - &system_reference_state, - )?; + output.line(&format!("State: {}", diagnostic.state.as_str()))?; + output.line(&format!("Evidence: {}", diagnostic.evidence.as_str()))?; + output.line(&format!( + "Expected redirects: HTTP {}, HTTPS {}", + display_port(diagnostic.expected_http_port), + display_port(diagnostic.expected_https_port), + ))?; + output.line(&format!( + "Active redirects: HTTP {}, HTTPS {}", + display_port(diagnostic.active_http_port), + display_port(diagnostic.active_https_port), + ))?; + output.line(&format!("Observed: {}", diagnostic.observed_at))?; + if !diagnostic.is_active() { + output.line("Repair: `pv ports:install`")?; + } - Ok(ExitCode::SUCCESS) + Ok(exit_code) +} + +fn display_port(port: Option) -> String { + port.map_or_else(|| "-".to_owned(), |port| port.to_string()) } pub(crate) fn install( @@ -281,124 +295,6 @@ fn pf_config_from_assignments(assignments: &GatewayPortAssignments) -> PfRedirec PfRedirectConfig::new(assignments.http.port, assignments.https.port) } -fn pf_config_from_anchor_state(state: &PfFileState) -> Option { - match state { - PfFileState::Current { value, .. } - | PfFileState::Stale { - actual: Some(value), - .. - } => Some(value.clone()), - PfFileState::Missing { .. } - | PfFileState::Stale { actual: None, .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - -fn pf_reference_from_state(state: &PfFileState) -> Option { - match state { - PfFileState::Current { value, .. } - | PfFileState::Stale { - actual: Some(value), - .. - } => Some(*value), - PfFileState::Missing { .. } - | PfFileState::Stale { actual: None, .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - -fn write_pf_anchor_state( - output: &mut Output<'_, impl Write>, - label: &str, - state: &PfFileState, -) -> io::Result<()> { - match state { - PfFileState::Missing { path } => { - output.line(&format!("{label}: missing"))?; - output.line(&format!(" path: {path}")) - } - PfFileState::Current { path, value } => { - output.line(&format!("{label}: current"))?; - output.line(&format!(" path: {path}"))?; - output.line(&format!( - " HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:{}", - value.http_port - ))?; - output.line(&format!( - " HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:{}", - value.https_port - )) - } - PfFileState::Stale { - path, - expected, - actual, - } => { - output.line(&format!("{label}: stale"))?; - output.line(&format!(" path: {path}"))?; - write_optional_pf_config(output, "expected", expected.as_ref())?; - write_optional_pf_config(output, "actual", actual.as_ref()) - } - PfFileState::Conflict { path } => { - output.line(&format!("{label}: not PV-owned"))?; - output.line(&format!(" path: {path}")) - } - PfFileState::Unreadable { path, message } => { - output.line(&format!("{label}: unreadable"))?; - output.line(&format!(" path: {path}"))?; - output.line(&format!(" {message}")) - } - } -} - -fn write_pf_reference_state( - output: &mut Output<'_, impl Write>, - label: &str, - state: &PfFileState, -) -> io::Result<()> { - match state { - PfFileState::Missing { path } => { - output.line(&format!("{label}: missing"))?; - output.line(&format!(" path: {path}")) - } - PfFileState::Current { path, .. } => { - output.line(&format!("{label}: current"))?; - output.line(&format!(" path: {path}"))?; - output.line(" anchor: com.prvious.pv") - } - PfFileState::Stale { path, .. } => { - output.line(&format!("{label}: stale"))?; - output.line(&format!(" path: {path}"))?; - output.line(" anchor: com.prvious.pv") - } - PfFileState::Conflict { path } => { - output.line(&format!("{label}: not PV-owned"))?; - output.line(&format!(" path: {path}")) - } - PfFileState::Unreadable { path, message } => { - output.line(&format!("{label}: unreadable"))?; - output.line(&format!(" path: {path}"))?; - output.line(&format!(" {message}")) - } - } -} - -fn write_optional_pf_config( - output: &mut Output<'_, impl Write>, - label: &str, - config: Option<&PfRedirectConfig>, -) -> io::Result<()> { - match config { - Some(config) => { - output.line(&format!(" {label} HTTP port: {}", config.http_port))?; - output.line(&format!(" {label} HTTPS port: {}", config.https_port)) - } - None => output.line(&format!(" {label}: unparseable")), - } -} - fn write_pf_install_blocker( output: &mut Output<'_, impl Write>, anchor_state: &PfFileState, diff --git a/crates/cli/src/commands/status.rs b/crates/cli/src/commands/status.rs index 40a3f22b..4f7d55b9 100644 --- a/crates/cli/src/commands/status.rs +++ b/crates/cli/src/commands/status.rs @@ -3,8 +3,8 @@ use std::process::ExitCode; use camino::Utf8PathBuf; use platform::{ - CaFileState, LaunchAgentFileState, LocalCaMetadata, PfConfReference, PfFileState, - ResolverConfig, ResolverFileState, TrustDomainState, + CaFileState, LaunchAgentFileState, LocalCaMetadata, ResolverConfig, ResolverFileState, + TrustDomainState, }; use serde::Serialize; use state::{ @@ -18,6 +18,8 @@ use crate::environment::Environment; use crate::error::{CliError, ExecuteError}; use crate::output::{Output, OutputMode}; +use super::pf_diagnostics::PfRoutingDiagnostic; + pub(crate) fn run( args: StatusArgs, environment: &impl Environment, @@ -60,7 +62,12 @@ impl StatusSnapshot { let paths = pv_paths(environment)?; let database = Database::open_read_only(&paths)?; let daemon = DaemonStatus::read(environment, &paths)?; - let integrations = IntegrationStatuses::read(environment, &paths)?; + let integrations = IntegrationStatuses::read( + environment, + &paths, + database.as_ref(), + daemon.state != "disabled", + )?; let runtime_states = match &database { Some(database) => database.runtime_observed_states()?, None => Vec::new(), @@ -115,7 +122,13 @@ impl StatusSnapshot { output.line(&format!(" Socket: {}", self.daemon.socket))?; output.line("Integrations:")?; output.line(&format!(" DNS: {}", self.integrations.dns))?; - output.line(&format!(" Ports: {}", self.integrations.ports))?; + output.line(&format!( + " Ports: {}", + self.integrations.ports.state.as_str() + ))?; + if !self.integrations.ports.is_active() { + output.line(" repair: `pv ports:install`")?; + } output.line(&format!(" CA: {}", self.integrations.ca))?; output.line(&format!("Logs: {}", self.log_directory))?; output.line("Managed Resources:")?; @@ -222,14 +235,19 @@ impl DaemonStatus { #[derive(Serialize)] struct IntegrationStatuses { dns: &'static str, - ports: &'static str, + ports: PfRoutingDiagnostic, ca: &'static str, #[serde(skip)] failure: bool, } impl IntegrationStatuses { - fn read(environment: &impl Environment, paths: &PvPaths) -> Result { + fn read( + environment: &impl Environment, + paths: &PvPaths, + database: Option<&Database>, + low_port_routing_required: bool, + ) -> Result { let prepared_resolver = platform::inspect_resolver_file(&paths.resolver_config(), None); let expected_resolver = resolver_config_from_state(&prepared_resolver); let system_resolver_path = resolver_test_path(environment)?; @@ -237,35 +255,8 @@ impl IntegrationStatuses { platform::inspect_resolver_file(&system_resolver_path, expected_resolver.as_ref()); let (dns, dns_failure) = resolver_status(&prepared_resolver, &system_resolver); - let prepared_pf_anchor = platform::inspect_pf_anchor_file(&paths.pf_anchor_config(), None); - let prepared_pf_reference = - platform::inspect_pf_conf_reference(&paths.pf_conf_reference_config(), None); - let expected_pf_anchor = pf_config_from_anchor_state(&prepared_pf_anchor); - let expected_pf_reference = pf_reference_from_state(&prepared_pf_reference); - let system_pf_anchor_path = pf_anchor_path(environment)?; - let system_pf_conf_path = pf_conf_path(environment)?; - let system_pf_anchor = - platform::inspect_pf_anchor_file(&system_pf_anchor_path, expected_pf_anchor.as_ref()); - let system_pf_reference = platform::inspect_pf_conf_reference( - &system_pf_conf_path, - expected_pf_reference.as_ref(), - ); - let active_pf = if pf_file_status(&prepared_pf_anchor, &prepared_pf_reference) == "current" - && pf_file_status(&system_pf_anchor, &system_pf_reference) == "current" - { - Some(environment.active_pf_redirect_config()) - } else { - None - }; - let (ports, ports_failure) = pf_status( - &prepared_pf_anchor, - &prepared_pf_reference, - &system_pf_anchor, - &system_pf_reference, - active_pf - .as_ref() - .and_then(|active| active.as_ref().map(|config| config.as_ref()).ok()), - ); + let ports = PfRoutingDiagnostic::read(environment, paths, database)?; + let ports_failure = low_port_routing_required && !ports.is_active(); let local_ca = platform::inspect_local_ca_files(&paths.ca_certificate(), &paths.ca_private_key()); @@ -511,51 +502,6 @@ fn resolver_status( } } -fn pf_status( - prepared_anchor: &PfFileState, - prepared_reference: &PfFileState, - system_anchor: &PfFileState, - system_reference: &PfFileState, - active: Option>, -) -> (&'static str, bool) { - let prepared_status = pf_file_status(prepared_anchor, prepared_reference); - if prepared_status != "current" { - return (prepared_status, prepared_status != "missing"); - } - - let system_status = pf_file_status(system_anchor, system_reference); - if system_status != "current" { - if system_status == "missing" { - return ("prepared-only", true); - } - - return (system_status, true); - } - - let Some(active) = active else { - return ("unreadable", true); - }; - let expected = pf_config_from_anchor_state(prepared_anchor); - if active == expected.as_ref() { - ("current", false) - } else { - ("inactive", true) - } -} - -fn pf_file_status( - anchor: &PfFileState, - reference: &PfFileState, -) -> &'static str { - match (anchor, reference) { - (PfFileState::Missing { .. }, PfFileState::Missing { .. }) => "missing", - (PfFileState::Current { .. }, PfFileState::Current { .. }) => "current", - (PfFileState::Conflict { .. }, _) | (_, PfFileState::Conflict { .. }) => "conflict", - (PfFileState::Unreadable { .. }, _) | (_, PfFileState::Unreadable { .. }) => "unreadable", - _ => "stale", - } -} - fn ca_status(state: &CaFileState, trust: &TrustDomainState) -> (&'static str, bool) { match state { CaFileState::Missing { .. } => ("missing", false), @@ -619,28 +565,6 @@ fn resolver_config_from_state(state: &ResolverFileState) -> Option, -) -> Option { - match state { - PfFileState::Current { value, .. } => Some(value.clone()), - PfFileState::Missing { .. } - | PfFileState::Stale { .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - -fn pf_reference_from_state(state: &PfFileState) -> Option { - match state { - PfFileState::Current { value, .. } => Some(*value), - PfFileState::Missing { .. } - | PfFileState::Stale { .. } - | PfFileState::Conflict { .. } - | PfFileState::Unreadable { .. } => None, - } -} - fn metadata_from_local_ca(state: &CaFileState) -> Option { match state { CaFileState::Current { metadata, .. } => Some(metadata.clone()), @@ -681,13 +605,3 @@ fn resolver_test_path(environment: &impl Environment) -> Result Result { - Utf8PathBuf::from_path_buf(environment.pf_anchor_path()) - .map_err(|path| CliError::NonUtf8Path { path }.into()) -} - -fn pf_conf_path(environment: &impl Environment) -> Result { - Utf8PathBuf::from_path_buf(environment.pf_conf_path()) - .map_err(|path| CliError::NonUtf8Path { path }.into()) -} diff --git a/crates/cli/src/environment.rs b/crates/cli/src/environment.rs index f578693e..774a5049 100644 --- a/crates/cli/src/environment.rs +++ b/crates/cli/src/environment.rs @@ -127,6 +127,20 @@ pub trait Environment { platform::active_pf_redirect_config_with_privilege_mode(privilege_mode) } + fn inspect_active_pf_redirects_unprivileged( + &self, + ) -> Result { + platform::inspect_active_pf_redirects_unprivileged() + } + + fn probe_gateway_redirects( + &self, + _expected: &platform::PfRedirectConfig, + _ca_certificate_path: &Utf8Path, + ) -> Result<(), String> { + Err("Gateway identity probing is unavailable in this environment".to_owned()) + } + fn remove_pf_redirects( &self, system_anchor_path: &Utf8Path, @@ -235,6 +249,15 @@ impl Environment for ProcessEnvironment { platform::open_url(url).map_err(io::Error::other) } + fn probe_gateway_redirects( + &self, + expected: &platform::PfRedirectConfig, + ca_certificate_path: &Utf8Path, + ) -> Result<(), String> { + daemon::gateway::probe_gateway_identity_blocking(expected, ca_certificate_path) + .map_err(|error| error.to_string()) + } + fn exec(&self, program: &Path, args: &[String]) -> io::Result { self.exec_with_env(program, args, &[]) } diff --git a/crates/cli/tests/doctor.rs b/crates/cli/tests/doctor.rs index 1e26aa0d..b7e249de 100644 --- a/crates/cli/tests/doctor.rs +++ b/crates/cli/tests/doctor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::ffi::OsString; use std::io::{self, BufRead as _, Write as _}; use std::os::unix::net::UnixListener; @@ -11,8 +12,8 @@ use camino_tempfile::tempdir; use cli::{Environment, run_with_environment}; use insta::{Settings, assert_debug_snapshot}; use platform::{ - KeychainCertificate, KeychainTrustResult, LaunchAgentConfig, PfConfReference, PfRedirectConfig, - ResolverConfig, + ActivePfRedirectInspection, KeychainCertificate, KeychainTrustResult, LaunchAgentConfig, + PfConfReference, PfRedirectConfig, ResolverConfig, }; use state::{Database, PvPaths, RuntimeObservedStatus, RuntimeSubject}; @@ -117,6 +118,20 @@ impl Environment for TestEnvironment { Ok(self.active_pf_config.borrow().clone()) } + fn inspect_active_pf_redirects_unprivileged( + &self, + ) -> Result { + let pv_config = self.active_pf_config.borrow().clone(); + let loopback_target_ports = pv_config.as_ref().map_or_else(BTreeSet::new, |config| { + BTreeSet::from([config.http_port, config.https_port]) + }); + + Ok(ActivePfRedirectInspection { + pv_config, + loopback_target_ports, + }) + } + fn trusted_ca_certificates(&self) -> Result, platform::PlatformError> { Ok(self.trusted_certificates.borrow().clone()) } @@ -133,13 +148,19 @@ fn doctor_passes_when_required_checks_pass() -> anyhow::Result<()> { let output = run_pv(&["doctor"], &environment)?; join_health_server(health_server)?; + state::fs::delete_file(&paths.daemon_socket())?; + let json_health_server = spawn_health_server(&paths.daemon_socket())?; + let json = run_pv(&["doctor", "--json"], &environment)?; + join_health_server(json_health_server)?; assert_eq!(output.exit_code, ExitCode::SUCCESS); assert!(output.stderr.is_empty()); + assert_eq!(json.exit_code, ExitCode::SUCCESS); + assert!(json.stderr.is_empty()); assert_doctor_snapshot( "doctor_passes_when_required_checks_pass", tempdir.path(), - output, + (output, json), ); Ok(()) @@ -329,7 +350,8 @@ fn seed_required_checks( environment: &TestEnvironment, include_manifest_cache: bool, ) -> anyhow::Result<()> { - Database::open(paths)?; + let mut database = Database::open(paths)?; + database.assign_gateway_ports(|port| port == 48080 || port == 48443)?; let launch_agent = LaunchAgentConfig::new( "/bin/pv", @@ -460,6 +482,7 @@ fn assert_doctor_snapshot(name: &'static str, tempdir: &Utf8Path, snapshot: impl settings.add_filter("/private", ""); settings.add_filter(r"job_[0-9]+", ""); settings.add_filter(r"[0-9a-f]{64}", ""); + settings.add_filter(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", ""); settings.bind(|| { assert_debug_snapshot!(name, snapshot); }); diff --git a/crates/cli/tests/ports.rs b/crates/cli/tests/ports.rs index 5b982443..324b8dde 100644 --- a/crates/cli/tests/ports.rs +++ b/crates/cli/tests/ports.rs @@ -9,7 +9,7 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use cli::{Environment, run_with_environment}; use insta::assert_debug_snapshot; -use platform::{PfConfReference, PfRedirectConfig}; +use platform::{ActivePfRedirectInspection, PfConfReference, PfRedirectConfig}; use state::{Database, PortOwner, PvPaths, StateError}; #[derive(Debug)] @@ -22,6 +22,8 @@ struct TestEnvironment { active_pf_config: RefCell>, active_pf_privilege_modes: RefCell>, active_pf_read_fails_when_unloaded: bool, + unprivileged_pf_inspection_fails: bool, + gateway_probe_succeeds: bool, operations: RefCell>, } @@ -41,6 +43,8 @@ impl TestEnvironment { active_pf_config: RefCell::new(None), active_pf_privilege_modes: RefCell::new(Vec::new()), active_pf_read_fails_when_unloaded: false, + unprivileged_pf_inspection_fails: false, + gateway_probe_succeeds: false, operations: RefCell::new(Vec::new()), } } @@ -54,6 +58,16 @@ impl TestEnvironment { self.active_pf_read_fails_when_unloaded = true; self } + + fn with_unprivileged_pf_inspection_failing(mut self) -> Self { + self.unprivileged_pf_inspection_fails = true; + self + } + + fn with_gateway_probe_succeeding(mut self) -> Self { + self.gateway_probe_succeeds = true; + self + } } impl Environment for TestEnvironment { @@ -143,6 +157,38 @@ impl Environment for TestEnvironment { Ok(self.active_pf_config.borrow().clone()) } + fn inspect_active_pf_redirects_unprivileged( + &self, + ) -> Result { + if self.unprivileged_pf_inspection_fails { + return Err(platform::PlatformError::SystemIntegrationCommandStatus { + command: "/sbin/pfctl -s nat".to_owned(), + status: "exit status: 1".to_owned(), + }); + } + let pv_config = self.active_pf_config.borrow().clone(); + let loopback_target_ports = pv_config.as_ref().map_or_else(BTreeSet::new, |config| { + BTreeSet::from([config.http_port, config.https_port]) + }); + + Ok(ActivePfRedirectInspection { + pv_config, + loopback_target_ports, + }) + } + + fn probe_gateway_redirects( + &self, + _expected: &PfRedirectConfig, + _ca_certificate_path: &Utf8Path, + ) -> Result<(), String> { + if self.gateway_probe_succeeds { + Ok(()) + } else { + Err("Gateway identity probe failed".to_owned()) + } + } + fn remove_pf_redirects( &self, system_anchor_path: &Utf8Path, @@ -381,8 +427,7 @@ fn ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts() } #[test] -fn ports_status_reports_prepared_and_system_pf_states_without_mutating_state() -> anyhow::Result<()> -{ +fn ports_status_reports_canonical_routing_states_without_mutating_state() -> anyhow::Result<()> { let tempdir = tempdir()?; let home = tempdir.path().join("home"); let current_dir = tempdir.path().join("work"); @@ -404,28 +449,99 @@ fn ports_status_reports_prepared_and_system_pf_states_without_mutating_state() - let prepared_anchor_after_missing = read_optional_file(&paths.pf_anchor_config())?; let prepared_reference_after_missing = read_optional_file(&paths.pf_conf_reference_config())?; + let mut database = Database::open(&paths)?; + database.assign_gateway_ports(|port| port == 48080 || port == 48443)?; + drop(database); write_file(&paths.pf_anchor_config(), ¤t_anchor)?; write_file(&paths.pf_conf_reference_config(), ¤t_reference)?; let prepared_only = run_pv(&["ports:status"], &environment)?; write_file(&system_anchor_path, ¤t_anchor)?; write_file(&system_pf_conf_path, ¤t_reference)?; + *environment.active_pf_config.borrow_mut() = Some(PfRedirectConfig::new(48080, 48443)); let current = run_pv(&["ports:status"], &environment)?; + let current_json = run_pv(&["ports:status", "--json"], &environment)?; write_file(&system_anchor_path, &stale_anchor)?; write_file(&system_pf_conf_path, "anchor \"com.prvious.pv\"\n")?; let stale_and_conflict = run_pv(&["ports:status"], &environment)?; - assert_eq!(missing.exit_code, ExitCode::SUCCESS); - assert_eq!(prepared_only.exit_code, ExitCode::SUCCESS); + assert_eq!(missing.exit_code, ExitCode::FAILURE); + assert_eq!(prepared_only.exit_code, ExitCode::FAILURE); assert_eq!(current.exit_code, ExitCode::SUCCESS); - assert_eq!(stale_and_conflict.exit_code, ExitCode::SUCCESS); + assert_eq!(current_json.exit_code, ExitCode::SUCCESS); + assert_eq!(stale_and_conflict.exit_code, ExitCode::FAILURE); assert!(database_after_missing.is_none()); assert!(prepared_anchor_after_missing.is_none()); assert!(prepared_reference_after_missing.is_none()); + assert!(environment.active_pf_privilege_modes.borrow().is_empty()); + + with_normalized_tempdir(tempdir.path(), || { + assert_debug_snapshot!(( + missing, + prepared_only, + current, + current_json, + stale_and_conflict, + )); + }); + + Ok(()) +} + +#[test] +fn ports_status_uses_gateway_identity_when_unprivileged_pfctl_is_denied() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let current_dir = tempdir.path().join("work"); + let system_anchor_path = tempdir.path().join("etc/pf.anchors/com.prvious.pv"); + let system_pf_conf_path = tempdir.path().join("etc/pf.conf"); + let paths = pv_paths(&home); + let config = PfRedirectConfig::new(48080, 48443); + let mut database = Database::open(&paths)?; + database.assign_gateway_ports(|port| port == 48080 || port == 48443)?; + drop(database); + write_file(&paths.pf_anchor_config(), &config.render_anchor())?; + write_file(&paths.pf_conf_reference_config(), &PfConfReference.render())?; + write_file(&system_anchor_path, &config.render_anchor())?; + write_file(&system_pf_conf_path, &PfConfReference.render())?; + + let working_environment = TestEnvironment::new( + &home, + ¤t_dir, + &system_anchor_path, + &system_pf_conf_path, + ) + .with_unprivileged_pf_inspection_failing() + .with_gateway_probe_succeeding(); + let broken_environment = TestEnvironment::new( + &home, + ¤t_dir, + &system_anchor_path, + &system_pf_conf_path, + ) + .with_unprivileged_pf_inspection_failing(); + + let working = run_pv(&["ports:status", "--json"], &working_environment)?; + let broken = run_pv(&["ports:status", "--json"], &broken_environment)?; + + assert_eq!(working.exit_code, ExitCode::SUCCESS); + assert_eq!(broken.exit_code, ExitCode::FAILURE); + assert!( + working_environment + .active_pf_privilege_modes + .borrow() + .is_empty() + ); + assert!( + broken_environment + .active_pf_privilege_modes + .borrow() + .is_empty() + ); with_normalized_tempdir(tempdir.path(), || { - assert_debug_snapshot!((missing, prepared_only, current, stale_and_conflict,)); + assert_debug_snapshot!((working, broken)); }); Ok(()) @@ -622,5 +738,6 @@ fn with_normalized_tempdir(tempdir: &Utf8Path, assertion: impl FnOnce()) { let mut settings = insta::Settings::clone_current(); settings.add_filter(tempdir.as_str(), ""); settings.add_filter("/private", ""); + settings.add_filter(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", ""); settings.bind(assertion); } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap index 6a5843a9..28c8d325 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: active pf redirects are not loaded\n repair: `pv ports:install`\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: low-port redirects are inactive\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP -, HTTPS -; observed: \n repair: `pv ports:install`\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap index 499fc9df..fc85c0cf 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap index 61aaf3ff..f95ae423 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: `pv ca:trust`\n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: `pv ca:trust`\n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap index 10b61f69..6a38c5eb 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: `pv dns:install`\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: `pv dns:install`\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap index 76411adf..09e98fa2 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[fail] Recent jobs: 1 failed job(s) in recent history\n reconcile system: Gateway failed to start\n repair: `pv setup`\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: `pv daemon:restart`\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 7 passed, 0 warning(s), 3 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n[fail] Recent jobs: 1 failed job(s) in recent history\n reconcile system: Gateway failed to start\n repair: `pv setup`\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: `pv daemon:restart`\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 7 passed, 0 warning(s), 3 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_is_read_only.snap b/crates/cli/tests/snapshots/doctor__doctor_is_read_only.snap index b6d54ed7..08ec6216 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_is_read_only.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_is_read_only.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[fail] State layout: missing ~/.pv state directory\n path: /home/.pv\n repair: `pv setup`\n[fail] Database: pv.db is missing\n path: /home/.pv/pv.db\n repair: `pv setup`\n[fail] Daemon LaunchAgent: LaunchAgent is missing\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n repair: `pv daemon:enable`\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:enable`\n[fail] DNS config: prepared resolver config is missing\n path: /home/.pv/config/resolver/test\n repair: `pv dns:install`\n[fail] Port redirect config: prepared pf config is missing\n path: /home/.pv/config/pf/com.prvious.pv\n repair: `pv ports:install`\n[fail] Local CA files: local CA files are missing\n certificate: /home/.pv/certificates/ca.pem; private key: /home/.pv/certificates/ca-key.pem\n repair: `pv ca:trust`\n[warn] Recent jobs: skipped because pv.db is missing\n repair: `pv setup`\n[warn] Runtime states: skipped because pv.db is missing\n repair: `pv setup`\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 0 passed, 3 warning(s), 7 failed\n", + stdout: "PV doctor\n[fail] State layout: missing ~/.pv state directory\n path: /home/.pv\n repair: `pv setup`\n[fail] Database: pv.db is missing\n path: /home/.pv/pv.db\n repair: `pv setup`\n[fail] Daemon LaunchAgent: LaunchAgent is missing\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n repair: `pv daemon:enable`\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:enable`\n[fail] DNS config: prepared resolver config is missing\n path: /home/.pv/config/resolver/test\n repair: `pv dns:install`\n[fail] Port redirect config: low-port redirects are inactive\n evidence: pfctl; expected: HTTP -, HTTPS -; active: HTTP -, HTTPS -; observed: \n repair: `pv ports:install`\n[fail] Local CA files: local CA files are missing\n certificate: /home/.pv/certificates/ca.pem; private key: /home/.pv/certificates/ca-key.pem\n repair: `pv ca:trust`\n[warn] Recent jobs: skipped because pv.db is missing\n repair: `pv setup`\n[warn] Runtime states: skipped because pv.db is missing\n repair: `pv setup`\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 0 passed, 3 warning(s), 7 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap index 49413b32..8b722370 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap @@ -2,12 +2,23 @@ source: crates/cli/tests/doctor.rs expression: snapshot --- -RunOutput { - exit_code: ExitCode( - unix_exit_status( - 0, +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), ), - ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 0 failed\n", - stderr: "", -} + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 0 failed\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "{\"checks\":[{\"status\":\"pass\",\"name\":\"State layout\",\"message\":\"9 PV-owned directories have user-only permissions\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Database\",\"message\":\"read-only open succeeded; 9 migrations applied\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Daemon LaunchAgent\",\"message\":\"PV-owned LaunchAgent is installed\",\"detail\":\"path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\",\"repair\":null},{\"status\":\"pass\",\"name\":\"Daemon socket\",\"message\":\"daemon answered health check\",\"detail\":\"path: /home/.pv/run/pv.sock\",\"repair\":null},{\"status\":\"pass\",\"name\":\"DNS config\",\"message\":\"system resolver uses port 35353\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Port redirect config\",\"message\":\"low-port routing is active\",\"detail\":\"evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \",\"repair\":null,\"routing\":{\"state\":\"active\",\"evidence\":\"pfctl\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":48080,\"active_https_port\":48443,\"observed_at\":\"\"}},{\"status\":\"pass\",\"name\":\"Local CA trust\",\"message\":\"system trust matches fingerprint \",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Recent jobs\",\"message\":\"no failed jobs in recent history\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Runtime states\",\"message\":\"no degraded or failed runtime observations\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Artifact manifest cache\",\"message\":\"cached manifest is present\",\"detail\":\"path: /home/.pv/downloads/manifest.json\",\"repair\":null}]}\n", + stderr: "", + }, +) diff --git a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap index 87fb0215..2b1fbd36 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 9 passed, 1 warning(s), 0 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 9 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 9 passed, 1 warning(s), 0 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/ports__ports_status_reports_canonical_routing_states_without_mutating_state.snap b/crates/cli/tests/snapshots/ports__ports_status_reports_canonical_routing_states_without_mutating_state.snap new file mode 100644 index 00000000..a267a230 --- /dev/null +++ b/crates/cli/tests/snapshots/ports__ports_status_reports_canonical_routing_states_without_mutating_state.snap @@ -0,0 +1,51 @@ +--- +source: crates/cli/tests/ports.rs +expression: "(missing, prepared_only, current, current_json, stale_and_conflict,)" +--- +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "Port redirect status\nState: inactive\nEvidence: pfctl\nExpected redirects: HTTP -, HTTPS -\nActive redirects: HTTP -, HTTPS -\nObserved: \nRepair: `pv ports:install`\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "Port redirect status\nState: inactive\nEvidence: pfctl\nExpected redirects: HTTP 48080, HTTPS 48443\nActive redirects: HTTP -, HTTPS -\nObserved: \nRepair: `pv ports:install`\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "Port redirect status\nState: active\nEvidence: pfctl\nExpected redirects: HTTP 48080, HTTPS 48443\nActive redirects: HTTP 48080, HTTPS 48443\nObserved: \n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "{\"state\":\"active\",\"evidence\":\"pfctl\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":48080,\"active_https_port\":48443,\"observed_at\":\"\"}\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "Port redirect status\nState: drifted\nEvidence: pfctl\nExpected redirects: HTTP 48080, HTTPS 48443\nActive redirects: HTTP 48080, HTTPS 48443\nObserved: \nRepair: `pv ports:install`\n", + stderr: "", + }, +) diff --git a/crates/cli/tests/snapshots/ports__ports_status_uses_gateway_identity_when_unprivileged_pfctl_is_denied.snap b/crates/cli/tests/snapshots/ports__ports_status_uses_gateway_identity_when_unprivileged_pfctl_is_denied.snap new file mode 100644 index 00000000..27a4d003 --- /dev/null +++ b/crates/cli/tests/snapshots/ports__ports_status_uses_gateway_identity_when_unprivileged_pfctl_is_denied.snap @@ -0,0 +1,24 @@ +--- +source: crates/cli/tests/ports.rs +expression: "(working, broken)" +--- +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "{\"state\":\"active\",\"evidence\":\"probe\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":48080,\"active_https_port\":48443,\"observed_at\":\"\"}\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "{\"state\":\"unknown\",\"evidence\":\"unavailable\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":null,\"active_https_port\":null,\"observed_at\":\"\"}\n", + stderr: "", + }, +) diff --git a/crates/cli/tests/snapshots/status__status_json_redacts_secret_context.snap b/crates/cli/tests/snapshots/status__status_json_redacts_secret_context.snap index 525ca6f8..1071ebd4 100644 --- a/crates/cli/tests/snapshots/status__status_json_redacts_secret_context.snap +++ b/crates/cli/tests/snapshots/status__status_json_redacts_secret_context.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "{\"overall\":\"failed\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":\"missing\",\"ca\":\"missing\"},\"managed_resources\":[{\"name\":\"mailpit\",\"track\":\"1\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"1.20.0-pv1\",\"failure\":false},{\"name\":\"mysql\",\"track\":\"8.0\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"8.0.36-pv1\",\"failure\":false},{\"name\":\"php\",\"track\":\"8.4\",\"desired\":\"installed\",\"status\":\"not-running\",\"projects\":0,\"version\":\"8.4.8-pv1\",\"failure\":false},{\"name\":\"postgres\",\"track\":\"16\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"16.4-pv1\",\"failure\":false},{\"name\":\"redis\",\"track\":\"7\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"7.2.5-pv1\",\"failure\":false},{\"name\":\"rustfs\",\"track\":\"1\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"1.0.0-pv1\",\"failure\":false}],\"runtimes\":[{\"subject\":\"worker:8.4\",\"status\":\"running\",\"message\":\"PHP worker is ready\",\"observed_at\":\"\",\"failure\":false}],\"projects\":[],\"recent_errors\":[{\"id\":\"job_000001\",\"kind\":\"reconcile\",\"scope\":\"resource:redis:7\",\"started_at\":\"\",\"finished_at\":\"\",\"error\":\"Redis failed readiness\"}],\"log_directory\":\"/home/.pv/logs\"}\n", + stdout: "{\"overall\":\"failed\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":{\"state\":\"inactive\",\"evidence\":\"pfctl\",\"expected_http_port\":null,\"expected_https_port\":null,\"active_http_port\":null,\"active_https_port\":null,\"observed_at\":\"\"},\"ca\":\"missing\"},\"managed_resources\":[{\"name\":\"mailpit\",\"track\":\"1\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"1.20.0-pv1\",\"failure\":false},{\"name\":\"mysql\",\"track\":\"8.0\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"8.0.36-pv1\",\"failure\":false},{\"name\":\"php\",\"track\":\"8.4\",\"desired\":\"installed\",\"status\":\"not-running\",\"projects\":0,\"version\":\"8.4.8-pv1\",\"failure\":false},{\"name\":\"postgres\",\"track\":\"16\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"16.4-pv1\",\"failure\":false},{\"name\":\"redis\",\"track\":\"7\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"7.2.5-pv1\",\"failure\":false},{\"name\":\"rustfs\",\"track\":\"1\",\"desired\":\"installed\",\"status\":\"running\",\"projects\":0,\"version\":\"1.0.0-pv1\",\"failure\":false}],\"runtimes\":[{\"subject\":\"worker:8.4\",\"status\":\"running\",\"message\":\"PHP worker is ready\",\"observed_at\":\"\",\"failure\":false}],\"projects\":[],\"recent_errors\":[{\"id\":\"job_000001\",\"kind\":\"reconcile\",\"scope\":\"resource:redis:7\",\"started_at\":\"\",\"finished_at\":\"\",\"error\":\"Redis failed readiness\"}],\"log_directory\":\"/home/.pv/logs\"}\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap b/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap index fa96295e..2e66a099 100644 --- a/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap +++ b/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap @@ -9,7 +9,7 @@ expression: snapshot 0, ), ), - stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\nRecent errors:\n none\n", + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\nRecent errors:\n none\n", stderr: "", }, RunOutput { @@ -18,7 +18,7 @@ expression: snapshot 0, ), ), - stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":\"missing\",\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"mode\":\"served\",\"slug\":\"project\",\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", + stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":{\"state\":\"inactive\",\"evidence\":\"pfctl\",\"expected_http_port\":null,\"expected_https_port\":null,\"active_http_port\":null,\"active_https_port\":null,\"observed_at\":\"\"},\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"mode\":\"served\",\"slug\":\"project\",\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", stderr: "", }, ) diff --git a/crates/cli/tests/snapshots/status__status_reports_current_launch_agent_with_stale_socket_as_down.snap b/crates/cli/tests/snapshots/status__status_reports_current_launch_agent_with_stale_socket_as_down.snap index 8af08a80..83f1cfe2 100644 --- a/crates/cli/tests/snapshots/status__status_reports_current_launch_agent_with_stale_socket_as_down.snap +++ b/crates/cli/tests/snapshots/status__status_reports_current_launch_agent_with_stale_socket_as_down.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV status\nOverall: failed\nDaemon: down\n LaunchAgent: current\n Socket: unhealthy\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", + stdout: "PV status\nOverall: failed\nDaemon: down\n LaunchAgent: current\n Socket: unhealthy\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_disabled_daemon_without_setup.snap b/crates/cli/tests/snapshots/status__status_reports_disabled_daemon_without_setup.snap index 4c12653c..c0e0bc02 100644 --- a/crates/cli/tests/snapshots/status__status_reports_disabled_daemon_without_setup.snap +++ b/crates/cli/tests/snapshots/status__status_reports_disabled_daemon_without_setup.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_dns_and_ports_repair_required_as_failure.snap b/crates/cli/tests/snapshots/status__status_reports_dns_and_ports_repair_required_as_failure.snap index a5da434a..d82d9839 100644 --- a/crates/cli/tests/snapshots/status__status_reports_dns_and_ports_repair_required_as_failure.snap +++ b/crates/cli/tests/snapshots/status__status_reports_dns_and_ports_repair_required_as_failure.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: prepared-only\n Ports: prepared-only\n CA: current\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", + stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: prepared-only\n Ports: inactive\n repair: `pv ports:install`\n CA: current\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n none\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_failed_jobs_as_failure.snap b/crates/cli/tests/snapshots/status__status_reports_failed_jobs_as_failure.snap index dd3742bf..f9138984 100644 --- a/crates/cli/tests/snapshots/status__status_reports_failed_jobs_as_failure.snap +++ b/crates/cli/tests/snapshots/status__status_reports_failed_jobs_as_failure.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n job_000001 reconcile project:acme failed: Gateway failed to start\n", + stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n none\nRecent errors:\n job_000001 reconcile project:acme failed: Gateway failed to start\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_pending_project_env_as_success.snap b/crates/cli/tests/snapshots/status__status_reports_pending_project_env_as_success.snap index 4c8691f1..55e30ddf 100644 --- a/crates/cli/tests/snapshots/status__status_reports_pending_project_env_as_success.snap +++ b/crates/cli/tests/snapshots/status__status_reports_pending_project_env_as_success.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=pending waiting for reconciliation\nRecent errors:\n none\n", + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=pending waiting for reconciliation\nRecent errors:\n none\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_project_env_failures_as_failure.snap b/crates/cli/tests/snapshots/status__status_reports_project_env_failures_as_failure.snap index 98e1b72e..7b9a7c09 100644 --- a/crates/cli/tests/snapshots/status__status_reports_project_env_failures_as_failure.snap +++ b/crates/cli/tests/snapshots/status__status_reports_project_env_failures_as_failure.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=failed missing required env placeholder\nRecent errors:\n none\n", + stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=failed missing required env placeholder\nRecent errors:\n none\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_runtime_and_resource_states.snap b/crates/cli/tests/snapshots/status__status_reports_runtime_and_resource_states.snap index 1c1898b8..47cc0a25 100644 --- a/crates/cli/tests/snapshots/status__status_reports_runtime_and_resource_states.snap +++ b/crates/cli/tests/snapshots/status__status_reports_runtime_and_resource_states.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n mailpit 1 running projects=0 version=1.20.0-pv1\n mysql 8.0 running projects=0 version=8.0.36-pv1\n php 8.4 not-running projects=0 version=8.4.8-pv1\n postgres 16 running projects=0 version=16.4-pv1\n redis 7 running projects=0 version=7.2.5-pv1\n rustfs 1 running projects=0 version=1.0.0-pv1\nRuntimes:\n worker:8.4 running PHP worker is ready\nProjects:\n none\nRecent errors:\n job_000001 reconcile resource:redis:7 failed: Redis failed readiness\n", + stdout: "PV status\nOverall: failed\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n mailpit 1 running projects=0 version=1.20.0-pv1\n mysql 8.0 running projects=0 version=8.0.36-pv1\n php 8.4 not-running projects=0 version=8.4.8-pv1\n postgres 16 running projects=0 version=16.4-pv1\n redis 7 running projects=0 version=7.2.5-pv1\n rustfs 1 running projects=0 version=1.0.0-pv1\nRuntimes:\n worker:8.4 running PHP worker is ready\nProjects:\n none\nRecent errors:\n job_000001 reconcile resource:redis:7 failed: Redis failed readiness\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap b/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap index a203b2f5..021cc83b 100644 --- a/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap +++ b/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap @@ -9,7 +9,7 @@ expression: snapshot 0, ), ), - stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`\nRecent errors:\n none\n", + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: inactive\n repair: `pv ports:install`\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`\nRecent errors:\n none\n", stderr: "", }, RunOutput { @@ -18,7 +18,7 @@ expression: snapshot 0, ), ), - stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":\"missing\",\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"mode\":\"served\",\"slug\":\"project\",\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", + stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":{\"state\":\"inactive\",\"evidence\":\"pfctl\",\"expected_http_port\":null,\"expected_https_port\":null,\"active_http_port\":null,\"active_https_port\":null,\"observed_at\":\"\"},\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"mode\":\"served\",\"slug\":\"project\",\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", stderr: "", }, ) diff --git a/crates/cli/tests/status.rs b/crates/cli/tests/status.rs index 1c1adc5d..b417ef12 100644 --- a/crates/cli/tests/status.rs +++ b/crates/cli/tests/status.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::io; use std::path::PathBuf; @@ -8,7 +8,10 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use cli::{Environment, run_with_environment}; use insta::{Settings, assert_debug_snapshot}; -use platform::{KeychainCertificate, PfConfReference, PfRedirectConfig, ResolverConfig}; +use platform::{ + ActivePfRedirectInspection, KeychainCertificate, PfConfReference, PfRedirectConfig, + ResolverConfig, +}; use platform::{KeychainTrustResult, LaunchAgentConfig}; use state::{ Database, LinkProjectInput, ManagedResourceTrackInstallInput, ProjectEnvObservedStatus, @@ -102,6 +105,15 @@ impl Environment for TestEnvironment { Ok(None) } + fn inspect_active_pf_redirects_unprivileged( + &self, + ) -> Result { + Ok(ActivePfRedirectInspection { + pv_config: None, + loopback_target_ports: BTreeSet::new(), + }) + } + fn trusted_ca_certificates(&self) -> Result, platform::PlatformError> { Ok(self.trusted_certificates.clone()) } diff --git a/crates/daemon/src/gateway.rs b/crates/daemon/src/gateway.rs index c06c1711..0bac54a9 100644 --- a/crates/daemon/src/gateway.rs +++ b/crates/daemon/src/gateway.rs @@ -20,9 +20,9 @@ use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::time::timeout; use crate::gateway_config::{ - GATEWAY_HEALTH_HOSTNAME, GATEWAY_HEALTH_PATH, GATEWAY_HEALTH_RESPONSE, GatewayConfigInput, - GatewayProjectRoute, PhpWorkerConfigInput, PhpWorkerProject, PromotedConfigDir, - PromotedConfigTree, promote_config_dir, promote_validated_config_tree_async, + GATEWAY_HEALTH_HOSTNAME, GATEWAY_HEALTH_PATH, GatewayConfigInput, GatewayProjectRoute, + PhpWorkerConfigInput, PhpWorkerProject, PromotedConfigDir, PromotedConfigTree, + gateway_health_response, promote_config_dir, promote_validated_config_tree_async, render_gateway_config, render_gateway_project_config, render_php_worker_config, render_php_worker_project_config, }; @@ -137,6 +137,25 @@ pub async fn reconcile_gateway_runtimes(paths: &PvPaths) -> Result Result<(), DaemonError> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build()?; + let check = gateway_identity_readiness_check( + expected.http_port, + expected.https_port, + PUBLIC_HTTP_PORT, + PUBLIC_HTTPS_PORT, + ca_certificate_path, + ); + + runtime.block_on(wait_for_readiness(check, Duration::from_secs(1))) +} + #[doc(hidden)] pub async fn reconcile_gateway_runtimes_with_readiness_timeout( paths: &PvPaths, @@ -437,16 +456,32 @@ fn gateway_readiness_check_for_ports( fn gateway_public_readiness_check( plan: &RuntimePlan, ports: GatewayReadinessPorts, +) -> ReadinessCheck { + gateway_identity_readiness_check( + plan.gateway.http_port, + plan.gateway.https_port, + ports.http, + ports.https, + &plan.gateway.ca_certificate_path, + ) +} + +fn gateway_identity_readiness_check( + expected_http_port: u16, + expected_https_port: u16, + probe_http_port: u16, + probe_https_port: u16, + ca_certificate_path: &Utf8Path, ) -> ReadinessCheck { ReadinessCheck::GatewayIdentity { http_host: "127.0.0.1".to_owned(), - http_port: ports.http, + http_port: probe_http_port, https_host: "127.0.0.1".to_owned(), - https_port: ports.https, + https_port: probe_https_port, server_name: GATEWAY_HEALTH_HOSTNAME.to_owned(), path: GATEWAY_HEALTH_PATH.to_owned(), - expected_body: GATEWAY_HEALTH_RESPONSE.to_owned(), - ca_certificate_path: plan.gateway.ca_certificate_path.clone(), + expected_body: gateway_health_response(expected_http_port, expected_https_port), + ca_certificate_path: ca_certificate_path.to_path_buf(), } } @@ -1822,7 +1857,7 @@ mod tests { https_port: 443, server_name: "pv-gateway.localhost".to_string(), path: "/__pv/health".to_string(), - expected_body: "pv-gateway-health-v1".to_string(), + expected_body: "pv-gateway-health-v1:45080:45443".to_string(), ca_certificate_path: Utf8PathBuf::from("/tmp/pv-missing-ca.pem"), } ); @@ -1939,7 +1974,7 @@ mod tests { https_port: 443, server_name: "pv-gateway.localhost".to_string(), path: "/__pv/health".to_string(), - expected_body: "pv-gateway-health-v1".to_string(), + expected_body: "pv-gateway-health-v1:45080:45443".to_string(), ca_certificate_path: Utf8PathBuf::from("/tmp/pv-missing-ca.pem"), } ); diff --git a/crates/daemon/src/gateway_config.rs b/crates/daemon/src/gateway_config.rs index e3b2e37c..3454d5bd 100644 --- a/crates/daemon/src/gateway_config.rs +++ b/crates/daemon/src/gateway_config.rs @@ -9,7 +9,10 @@ use crate::DaemonError; static CANDIDATE_CONFIG_COUNTER: AtomicU64 = AtomicU64::new(0); pub(crate) const GATEWAY_HEALTH_HOSTNAME: &str = "pv-gateway.localhost"; pub(crate) const GATEWAY_HEALTH_PATH: &str = "/__pv/health"; -pub(crate) const GATEWAY_HEALTH_RESPONSE: &str = "pv-gateway-health-v1"; + +pub(crate) fn gateway_health_response(http_port: u16, https_port: u16) -> String { + format!("pv-gateway-health-v1:{http_port}:{https_port}") +} #[derive(Clone, Debug, Eq, PartialEq)] pub struct GatewayConfigInput { @@ -49,6 +52,7 @@ pub struct PhpWorkerProject { pub fn render_gateway_config(input: &GatewayConfigInput) -> Result { let mut output = String::new(); + let health_response = gateway_health_response(input.http_port, input.https_port); output.push_str(&format!("# PV_FAKE_PORT {}\n", input.http_port)); output.push_str("{\n"); output.push_str(" admin off\n"); @@ -77,10 +81,10 @@ pub fn render_gateway_config(input: &GatewayConfigInput) -> Result https_port, server_name: "pv-gateway.localhost".to_owned(), path: "/__pv/health".to_owned(), - expected_body: "pv-gateway-health-v1".to_owned(), + expected_body: "pv-gateway-health-v1:48080:48443".to_owned(), ca_certificate_path, }, Duration::from_secs(1), @@ -316,7 +316,7 @@ async fn gateway_identity_readiness_rejects_generic_tcp_listeners() -> Result<() https_port, server_name: "pv-gateway.localhost".to_owned(), path: "/__pv/health".to_owned(), - expected_body: "pv-gateway-health-v1".to_owned(), + expected_body: "pv-gateway-health-v1:48080:48443".to_owned(), ca_certificate_path, }, Duration::from_millis(50), @@ -338,7 +338,7 @@ where let _bytes = stream.read(&mut request).await?; stream .write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nConnection: close\r\n\r\npv-gateway-health-v1", + b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\npv-gateway-health-v1:48080:48443", ) .await?; stream.shutdown().await