use super::*;
const DAEMON_LOG_MAX_BYTES: u64 = 8 * 1024 * 1024;
const DAEMON_LOG_RETAIN_BYTES: u64 = 2 * 1024 * 1024;
const DAEMON_LOG_COMPACT_CHECK_SECS: u64 = 60;
pub(crate) fn daemon_pid_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.pid")
}
pub(crate) fn visible_daemon_state_for_status(
running: bool,
state: Option<&DaemonRuntimeState>,
) -> Option<DaemonRuntimeState> {
if running { state.cloned() } else { None }
}
pub(crate) fn daemon_log_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.log")
}
pub(crate) fn redirect_stdio_to_daemon_log(config_path: &Path) -> Result<()> {
let log_path = daemon_log_file_path(config_path);
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("failed to open {}", log_path.display()))?;
let _ = set_daemon_runtime_file_permissions(&log_path);
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let fd = log_file.as_raw_fd();
unsafe {
if libc::dup2(fd, libc::STDOUT_FILENO) < 0 {
return Err(std::io::Error::last_os_error())
.context("failed to redirect stdout to daemon log");
}
if libc::dup2(fd, libc::STDERR_FILENO) < 0 {
return Err(std::io::Error::last_os_error())
.context("failed to redirect stderr to daemon log");
}
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
let handle = log_file.as_raw_handle();
unsafe {
#[link(name = "kernel32")]
unsafe extern "system" {
fn SetStdHandle(nStdHandle: u32, hHandle: *mut std::ffi::c_void) -> i32;
}
const STD_OUTPUT_HANDLE: u32 = (-11i32) as u32;
const STD_ERROR_HANDLE: u32 = (-12i32) as u32;
if SetStdHandle(STD_OUTPUT_HANDLE, handle as *mut _) == 0 {
return Err(std::io::Error::last_os_error())
.context("failed to redirect stdout to daemon log");
}
if SetStdHandle(STD_ERROR_HANDLE, handle as *mut _) == 0 {
return Err(std::io::Error::last_os_error())
.context("failed to redirect stderr to daemon log");
}
}
std::mem::forget(log_file);
}
Ok(())
}
pub(crate) fn compact_daemon_log_if_needed(config_path: &Path) -> Result<bool> {
compact_log_file_if_needed(
&daemon_log_file_path(config_path),
DAEMON_LOG_MAX_BYTES,
DAEMON_LOG_RETAIN_BYTES,
)
}
pub(crate) fn daemon_log_compact_check_due(last_checked: &mut Instant) -> bool {
if last_checked.elapsed() < Duration::from_secs(DAEMON_LOG_COMPACT_CHECK_SECS) {
return false;
}
*last_checked = Instant::now();
true
}
pub(crate) fn compact_log_file_if_needed(
path: &Path,
max_bytes: u64,
retain_bytes: u64,
) -> Result<bool> {
if max_bytes == 0 || retain_bytes == 0 || retain_bytes >= max_bytes {
return Ok(false);
}
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(error).with_context(|| format!("failed to inspect {}", path.display()));
}
};
let original_len = metadata.len();
if original_len <= max_bytes {
return Ok(false);
}
let retain_start = original_len.saturating_sub(retain_bytes);
let mut file = OpenOptions::new()
.read(true)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(retain_start))
.with_context(|| format!("failed to seek {}", path.display()))?;
let mut retained = Vec::with_capacity(retain_bytes as usize);
std::io::Read::read_to_end(&mut file, &mut retained)
.with_context(|| format!("failed to read {}", path.display()))?;
if let Some(newline) = retained.iter().position(|byte| *byte == b'\n') {
retained.drain(..=newline);
}
let header = format!(
"[nvpn] daemon log compacted at {}; retained {} bytes from {} bytes\n",
unix_timestamp(),
retained.len(),
original_len
);
let mut file = OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.with_context(|| format!("failed to compact {}", path.display()))?;
std::io::Write::write_all(&mut file, header.as_bytes())
.with_context(|| format!("failed to write compaction header to {}", path.display()))?;
std::io::Write::write_all(&mut file, &retained)
.with_context(|| format!("failed to write retained log tail to {}", path.display()))?;
std::io::Write::flush(&mut file)
.with_context(|| format!("failed to flush {}", path.display()))?;
Ok(true)
}
pub(crate) fn daemon_state_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.state.json")
}
#[cfg(any(target_os = "macos", test))]
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn daemon_network_cleanup_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.cleanup.json")
}
pub(crate) fn daemon_control_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.control")
}
pub(crate) fn daemon_control_result_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("daemon.control.result.json")
}
pub(crate) fn daemon_staged_config_file_path(config_path: &Path) -> PathBuf {
let parent = config_path
.parent()
.map_or_else(|| Path::new(".").to_path_buf(), PathBuf::from);
parent.join("config.pending.toml")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DaemonControlResult {
request: String,
ok: bool,
error: Option<String>,
}
pub(crate) fn ensure_no_other_daemon_processes_for_config(
config_path: &Path,
current_pid: u32,
) -> Result<()> {
let daemon_pids = daemon_candidate_pids(config_path, current_pid)?;
if let Some(existing_pid) = daemon_pids.first().copied() {
return Err(anyhow!("daemon already running with pid {}", existing_pid));
}
Ok(())
}
pub(crate) fn write_daemon_control_request(
config_path: &Path,
request: DaemonControlRequest,
) -> Result<()> {
let control_file = daemon_control_file_path(config_path);
if let Some(parent) = control_file.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&control_file, format!("{}\n", request.as_str())).with_context(|| {
format!(
"failed to write daemon control request {}",
control_file.display()
)
})?;
set_daemon_runtime_file_permissions(&control_file)?;
project_daemon_vpn_enabled_request(config_path, request);
Ok(())
}
fn project_daemon_vpn_enabled_request(config_path: &Path, request: DaemonControlRequest) {
let Some(vpn_enabled) = (match request {
DaemonControlRequest::Pause => Some(false),
DaemonControlRequest::Resume => Some(true),
DaemonControlRequest::Reload | DaemonControlRequest::Stop => None,
}) else {
return;
};
let state_file = daemon_state_file_path(config_path);
let Ok(Some(mut state)) = read_daemon_state(&state_file) else {
return;
};
state.updated_at = unix_timestamp();
state.vpn_enabled = vpn_enabled;
state.vpn_status = match (vpn_enabled, state.vpn_active) {
(true, true) if state.vpn_status == "Turning VPN off" || state.vpn_status == "Paused" => {
"VPN on".to_string()
}
(true, true) => state.vpn_status,
(true, false) => "Turning VPN on".to_string(),
(false, true) => "Turning VPN off".to_string(),
(false, false) => "Paused".to_string(),
};
if let Err(error) = write_daemon_state(&state_file, &state) {
eprintln!(
"daemon: failed to project VPN control state {}: {}",
state_file.display(),
error
);
}
}
pub(crate) fn clear_daemon_control_result(config_path: &Path) {
let _ = fs::remove_file(daemon_control_result_file_path(config_path));
}
pub(crate) fn write_daemon_control_result(
config_path: &Path,
request: DaemonControlRequest,
result: Result<()>,
) -> Result<()> {
let result_file = daemon_control_result_file_path(config_path);
if let Some(parent) = result_file.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let payload = match result {
Ok(()) => DaemonControlResult {
request: request.as_str().to_string(),
ok: true,
error: None,
},
Err(error) => DaemonControlResult {
request: request.as_str().to_string(),
ok: false,
error: Some(error.to_string()),
},
};
let raw = serde_json::to_vec_pretty(&payload)?;
write_runtime_file_atomically(&result_file, &raw)
.with_context(|| format!("failed to write {}", result_file.display()))?;
set_daemon_runtime_file_permissions(&result_file)?;
Ok(())
}
pub(crate) fn read_daemon_control_result(
config_path: &Path,
) -> Result<Option<DaemonControlResult>> {
let result_file = daemon_control_result_file_path(config_path);
if !result_file.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&result_file)
.with_context(|| format!("failed to read {}", result_file.display()))?;
let parsed = serde_json::from_str::<DaemonControlResult>(&raw)
.with_context(|| format!("failed to parse {}", result_file.display()))?;
Ok(Some(parsed))
}
pub(crate) fn wait_for_daemon_control_result(
config_path: &Path,
request: DaemonControlRequest,
timeout: Duration,
) -> Result<()> {
let result_file = daemon_control_result_file_path(config_path);
let started = Instant::now();
while started.elapsed() < timeout {
if let Some(result) = read_daemon_control_result(config_path)?
&& result.request == request.as_str()
{
let _ = fs::remove_file(&result_file);
return if result.ok {
Ok(())
} else {
Err(anyhow!(
"{}",
result
.error
.unwrap_or_else(|| "daemon control request failed".to_string())
))
};
}
thread::sleep(Duration::from_millis(100));
}
Err(anyhow!(
"daemon did not report result for {} within {}s; background service may be busy or stuck. try again, or restart/reinstall the app/service if it keeps happening",
request.as_str(),
timeout.as_secs()
))
}
pub(crate) fn stage_daemon_config_apply(config_path: &Path, source_path: &Path) -> Result<()> {
let staged_path = daemon_staged_config_file_path(config_path);
if let Some(parent) = staged_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let raw = fs::read(source_path)
.with_context(|| format!("failed to read source config {}", source_path.display()))?;
write_runtime_file_atomically(&staged_path, &raw)
.with_context(|| format!("failed to stage config {}", staged_path.display()))?;
set_private_cache_file_permissions(&staged_path)?;
Ok(())
}
pub(crate) fn update_daemon_config_from_staged_request(config_path: &Path) -> Result<bool> {
let staged_path = daemon_staged_config_file_path(config_path);
if !staged_path.exists() {
return Ok(false);
}
let result = apply_config_file(&staged_path, config_path);
let _ = fs::remove_file(&staged_path);
result?;
Ok(true)
}
pub(crate) fn request_daemon_stop(config_path: &Path) -> Result<()> {
write_daemon_control_request(config_path, DaemonControlRequest::Stop)
}
pub(crate) fn request_daemon_reload(config_path: &Path) -> Result<()> {
write_daemon_control_request(config_path, DaemonControlRequest::Reload)
}
pub(crate) fn apply_config_via_running_daemon(
source_path: &Path,
config_path: &Path,
) -> Result<()> {
let status = daemon_status(config_path)?;
if !status.running {
#[cfg(target_os = "windows")]
{
let service_status = service_management::windows_query_service_status()?;
if windows_should_apply_config_via_service(&service_status) {
apply_config_file(source_path, config_path)?;
service_management::windows_start_service_and_wait(true, Duration::from_secs(10))?;
return Ok(());
}
}
return Err(anyhow!("daemon: not running"));
}
clear_daemon_control_result(config_path);
stage_daemon_config_apply(config_path, source_path)?;
request_daemon_reload(config_path)?;
wait_for_daemon_control_ack(
config_path,
crate::daemon_control_ack_timeout(DaemonControlRequest::Reload),
)?;
wait_for_daemon_control_result(
config_path,
DaemonControlRequest::Reload,
crate::daemon_control_result_timeout(DaemonControlRequest::Reload),
)
}
pub(crate) fn wait_for_daemon_control_ack(config_path: &Path, timeout: Duration) -> Result<()> {
let control_file = daemon_control_file_path(config_path);
let started = Instant::now();
while started.elapsed() < timeout {
if !control_file.exists() {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
Err(anyhow!(
"daemon did not acknowledge control request within {}s; background service may be busy or stuck. try again, or restart/reinstall the app/service if it keeps happening",
timeout.as_secs()
))
}
#[cfg(test)]
pub(crate) fn wait_for_daemon_vpn_enabled(
config_path: &Path,
expected_enabled: bool,
timeout: Duration,
) -> Result<()> {
let started = Instant::now();
while started.elapsed() < timeout {
if let Ok(status) = daemon_status(config_path) {
let current_state = status.state.as_ref();
let current_enabled = current_state
.map(|state| state.vpn_enabled)
.unwrap_or(status.running);
let resumed_waiting_for_participants = expected_enabled
&& current_state
.is_some_and(|state| state.vpn_status == WAITING_FOR_PARTICIPANTS_STATUS);
if current_enabled == expected_enabled || resumed_waiting_for_participants {
return Ok(());
}
}
thread::sleep(Duration::from_millis(100));
}
let verb = if expected_enabled { "resume" } else { "pause" };
Err(anyhow!(
"daemon acknowledged control request but did not {verb} within {}s; background service may be busy or stuck. try again, or restart/reinstall the app/service if it keeps happening",
timeout.as_secs()
))
}
pub(crate) fn take_daemon_control_request(config_path: &Path) -> Option<DaemonControlRequest> {
let control_file = daemon_control_file_path(config_path);
let raw = match fs::read_to_string(&control_file) {
Ok(raw) => raw,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"daemon: failed to read control request {}: {}",
control_file.display(),
error
);
}
return None;
}
};
let _ = fs::remove_file(&control_file);
DaemonControlRequest::parse(&raw)
}
pub(crate) fn read_daemon_pid_record(path: &Path) -> Result<Option<DaemonPidRecord>> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path)
.with_context(|| format!("failed to read daemon pid file {}", path.display()))?;
let parsed = serde_json::from_str::<DaemonPidRecord>(&raw)
.with_context(|| format!("failed to parse daemon pid file {}", path.display()))?;
Ok(Some(parsed))
}
pub(crate) fn write_daemon_pid_record(path: &Path, record: &DaemonPidRecord) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let raw = serde_json::to_string_pretty(record)?;
write_runtime_file_atomically(path, raw.as_bytes())
.with_context(|| format!("failed to write daemon pid file {}", path.display()))?;
set_daemon_runtime_file_permissions(path)?;
Ok(())
}
pub(crate) fn read_daemon_state(path: &Path) -> Result<Option<DaemonRuntimeState>> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read(path)
.with_context(|| format!("failed to read daemon state file {}", path.display()))?;
match serde_json::from_slice::<DaemonRuntimeState>(&raw) {
Ok(parsed) => Ok(Some(parsed)),
Err(parse_error) => {
let trimmed = trim_runtime_json_padding(&raw);
if trimmed.len() != raw.len()
&& !trimmed.is_empty()
&& let Ok(parsed) = serde_json::from_slice::<DaemonRuntimeState>(trimmed)
{
if let Err(error) = write_runtime_file_atomically(path, trimmed) {
eprintln!(
"daemon: parsed padded state file {} but failed to rewrite clean copy: {}",
path.display(),
error
);
} else {
let _ = set_daemon_runtime_file_permissions(path);
}
return Ok(Some(parsed));
}
quarantine_corrupt_runtime_file(path, "daemon state", &parse_error);
Ok(None)
}
}
}
pub(crate) fn write_daemon_state(path: &Path, state: &DaemonRuntimeState) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let raw = serde_json::to_string_pretty(state)?;
write_runtime_file_atomically(path, raw.as_bytes())
.with_context(|| format!("failed to write daemon state file {}", path.display()))?;
set_daemon_runtime_file_permissions(path)?;
Ok(())
}
#[cfg(any(target_os = "macos", test))]
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn read_daemon_network_cleanup_state(
path: &Path,
) -> Result<Option<MacosNetworkCleanupState>> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read(path)
.with_context(|| format!("failed to read daemon cleanup file {}", path.display()))?;
match serde_json::from_slice::<MacosNetworkCleanupState>(&raw) {
Ok(parsed) => Ok(Some(parsed)),
Err(parse_error) => {
let trimmed = trim_runtime_json_padding(&raw);
if trimmed.len() != raw.len()
&& !trimmed.is_empty()
&& let Ok(parsed) = serde_json::from_slice::<MacosNetworkCleanupState>(trimmed)
{
if let Err(error) = write_runtime_file_atomically(path, trimmed) {
eprintln!(
"daemon: parsed padded cleanup file {} but failed to rewrite clean copy: {}",
path.display(),
error
);
} else {
let _ = set_daemon_runtime_file_permissions(path);
}
return Ok(Some(parsed));
}
quarantine_corrupt_runtime_file(path, "daemon cleanup", &parse_error);
Ok(None)
}
}
}
#[cfg(any(target_os = "macos", test))]
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn write_daemon_network_cleanup_state(
path: &Path,
state: &MacosNetworkCleanupState,
) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let raw = serde_json::to_string_pretty(state)?;
write_runtime_file_atomically(path, raw.as_bytes())
.with_context(|| format!("failed to write daemon cleanup file {}", path.display()))?;
set_daemon_runtime_file_permissions(path)?;
Ok(())
}
#[cfg(any(target_os = "macos", test))]
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn remove_runtime_file_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())),
}
}
pub(crate) fn persist_daemon_network_cleanup_state(
config_path: &Path,
tunnel_runtime: &CliTunnelRuntime,
) -> Result<()> {
#[cfg(target_os = "macos")]
{
let path = daemon_network_cleanup_file_path(config_path);
if let Some(state) = tunnel_runtime.macos_network_cleanup_state() {
write_daemon_network_cleanup_state(&path, &state)?;
} else {
remove_runtime_file_if_exists(&path)?;
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = (config_path, tunnel_runtime);
}
Ok(())
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_route_delete_error_is_absent(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("not in table")
|| lower.contains("no such process")
|| lower.contains("no such route")
|| lower.contains("bad interface name")
|| lower.contains("not a network interface")
}
#[cfg(target_os = "macos")]
fn macos_cleanup_managed_routes(state: &MacosNetworkCleanupState) -> Vec<MacosManagedRoute> {
let mut routes = state.managed_routes.clone();
if routes.is_empty() {
routes.extend(state.endpoint_bypass_routes.iter().cloned().map(|target| {
MacosManagedRoute {
target,
gateway: None,
interface: None,
}
}));
if state.original_default_route.is_some() && !state.iface.trim().is_empty() {
routes.extend(
crate::macos_network::macos_tunnel_default_route_targets()
.iter()
.map(|target| MacosManagedRoute {
target: (*target).to_string(),
gateway: None,
interface: Some(state.iface.clone()),
}),
);
}
}
routes.sort_by(|left, right| {
(
left.target.as_str(),
left.gateway.as_deref().unwrap_or(""),
left.interface.as_deref().unwrap_or(""),
)
.cmp(&(
right.target.as_str(),
right.gateway.as_deref().unwrap_or(""),
right.interface.as_deref().unwrap_or(""),
))
});
routes.dedup();
routes
}
#[cfg(target_os = "macos")]
pub(crate) fn repair_legacy_macos_network_state(config_path: &Path) -> Result<bool> {
let app = load_or_default_config(config_path)?;
let mut repaired = false;
if let Ok(tunnel_ip) = strip_cidr(&app.node.tunnel_ip).parse::<Ipv4Addr>() {
let default_routes = macos_default_routes()?;
let underlay_default =
macos_underlay_default_route_from_routes(&default_routes).or_else(|| {
crate::macos_network::macos_underlay_default_route_from_system()
.ok()
.flatten()
});
let mut tunnel_default_ifaces = Vec::new();
for route in &default_routes {
if !route.interface.starts_with("utun") {
continue;
}
match macos_iface_has_ipv4_address(&route.interface, tunnel_ip) {
Ok(true) => tunnel_default_ifaces.push(route.interface.clone()),
Ok(false) => {}
Err(error) => {
eprintln!(
"repair-network: failed to inspect macOS interface {}: {}",
route.interface, error
);
}
}
}
if tunnel_default_ifaces.is_empty() {
tunnel_default_ifaces =
crate::macos_network::macos_tunnel_interfaces_with_ipv4(tunnel_ip)?;
}
tunnel_default_ifaces.sort();
tunnel_default_ifaces.dedup();
let should_restore_underlay_default = default_routes.is_empty()
|| default_routes
.iter()
.all(|route| route.interface.starts_with("utun"));
if let Some(underlay_default) = underlay_default {
for iface in tunnel_default_ifaces {
match delete_macos_default_route_for_interface(&iface) {
Ok(()) => repaired = true,
Err(error) if macos_route_delete_error_is_absent(&error.to_string()) => {}
Err(error) => {
return Err(error).with_context(|| {
format!("failed to remove legacy macOS default route on {iface}")
});
}
}
}
if repaired || should_restore_underlay_default {
restore_macos_default_route(&underlay_default)
.context("failed to restore legacy macOS default route")?;
repaired = true;
}
}
}
let route_families =
linux_exit_node_default_route_families(&runtime_effective_advertised_routes(&app));
if route_families.ipv4 {
if let Err(error) = cleanup_macos_pf_nat() {
eprintln!("repair-network: failed to clear legacy macOS PF NAT rules: {error}");
} else {
repaired = true;
}
match read_macos_ip_forward() {
Ok(true) => {
write_macos_ip_forward(false)
.context("failed to restore legacy macOS IPv4 forwarding state")?;
repaired = true;
}
Ok(false) => {}
Err(error) => {
return Err(error).context("failed to read legacy macOS IPv4 forwarding state");
}
}
}
Ok(repaired)
}
pub(crate) fn repair_saved_network_state(config_path: &Path) -> Result<bool> {
#[cfg(test)]
crate::TEST_REPAIR_SAVED_NETWORK_STATE_CALLS.fetch_add(1, Ordering::Relaxed);
#[cfg(target_os = "macos")]
{
let path = daemon_network_cleanup_file_path(config_path);
let Some(state) = read_daemon_network_cleanup_state(&path)? else {
return repair_legacy_macos_network_state(config_path);
};
let managed_routes = macos_cleanup_managed_routes(&state);
let using_legacy_route_cleanup = state.managed_routes.is_empty();
let mut failures = Vec::new();
for route in &managed_routes {
if let Err(error) = delete_macos_managed_route(
&route.target,
route.gateway.as_deref(),
route.interface.as_deref(),
) && !macos_route_delete_error_is_absent(&error.to_string())
{
failures.push(format!("remove managed route {}: {error}", route.target));
}
}
if let Some(route) = state.original_default_route.as_ref()
&& let Err(error) = restore_macos_default_route(route)
{
failures.push(format!("restore default route: {error}"));
}
if state.pf_was_enabled.is_some() {
if let Err(error) = cleanup_macos_pf_nat() {
failures.push(format!("remove PF NAT rules: {error}"));
}
if state.pf_was_enabled == Some(false)
&& let Err(error) = run_checked(ProcessCommand::new("pfctl").arg("-d"))
{
failures.push(format!("restore PF enabled state: {error}"));
}
}
if let Some(previous) = state.ipv4_forward_was_enabled
&& let Err(error) = write_macos_ip_forward(previous)
{
failures.push(format!("restore IPv4 forwarding: {error}"));
}
if using_legacy_route_cleanup
&& let Err(error) = repair_legacy_macos_network_state(config_path)
{
failures.push(format!("repair legacy macOS routes: {error}"));
}
if !failures.is_empty() {
return Err(anyhow!(failures.join("; ")))
.with_context(|| format!("failed to repair {}", path.display()));
}
remove_runtime_file_if_exists(&path)?;
Ok(true)
}
#[cfg(not(target_os = "macos"))]
{
let _ = config_path;
Ok(false)
}
}
pub(crate) fn write_runtime_file_atomically(path: &Path, contents: &[u8]) -> Result<()> {
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
let parent = path
.parent()
.ok_or_else(|| anyhow!("runtime file has no parent: {}", path.display()))?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("runtime");
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let mut temp_file = None;
let mut temp_path = None;
for attempt in 0..128u32 {
let candidate = parent.join(format!(
".{file_name}.tmp-{}-{nonce}-{attempt}",
std::process::id()
));
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
options.mode(0o600);
}
match options.open(&candidate) {
Ok(file) => {
temp_file = Some(file);
temp_path = Some(candidate);
break;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(error).with_context(|| {
format!("failed to create temp runtime file {}", candidate.display())
});
}
}
}
let temp_path = temp_path.ok_or_else(|| {
anyhow!(
"failed to allocate temp runtime file for {}",
path.display()
)
})?;
let mut file = temp_file.expect("temp file set with temp path");
if let Err(error) = file.write_all(contents) {
let _ = fs::remove_file(&temp_path);
return Err(error)
.with_context(|| format!("failed to write temp runtime file {}", temp_path.display()));
}
drop(file);
fs::rename(&temp_path, path).with_context(|| {
format!(
"failed to replace {} with {}",
path.display(),
temp_path.display()
)
})?;
Ok(())
}
pub(crate) fn trim_runtime_json_padding(raw: &[u8]) -> &[u8] {
let start = raw
.iter()
.position(|byte| *byte != 0 && !byte.is_ascii_whitespace())
.unwrap_or(raw.len());
let end = raw
.iter()
.rposition(|byte| *byte != 0 && !byte.is_ascii_whitespace())
.map(|index| index + 1)
.unwrap_or(start);
&raw[start..end]
}
pub(crate) fn quarantine_corrupt_runtime_file(
path: &Path,
label: &str,
parse_error: &serde_json::Error,
) {
let Some(parent) = path.parent() else {
eprintln!(
"daemon: ignoring corrupt {label} file {}: {}",
path.display(),
parse_error
);
return;
};
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("runtime");
let quarantined = parent.join(format!(
"{file_name}.corrupt-{}-{}",
std::process::id(),
unix_timestamp()
));
match fs::rename(path, &quarantined) {
Ok(()) => eprintln!(
"daemon: ignoring corrupt {label} file {}: {}; moved aside to {}",
path.display(),
parse_error,
quarantined.display()
),
Err(rename_error) => eprintln!(
"daemon: ignoring corrupt {label} file {}: {}; failed to move aside: {}",
path.display(),
parse_error,
rename_error
),
}
}
pub(crate) fn spawn_daemon_process(args: &ConnectArgs, config_path: &Path) -> Result<u32> {
if let Some(existing_pid) = daemon_candidate_pids(config_path, std::process::id())?
.into_iter()
.next()
{
return Err(anyhow!("daemon already running with pid {}", existing_pid));
}
let log_file_path = daemon_log_file_path(config_path);
if let Some(parent) = log_file_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_file_path)
.with_context(|| format!("failed to truncate {}", log_file_path.display()))?;
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_file_path)
.with_context(|| format!("failed to open {}", log_file_path.display()))?;
let _ = set_daemon_runtime_file_permissions(&log_file_path);
let stderr_log = log_file
.try_clone()
.context("failed to clone daemon log file handle")?;
let mut command = ProcessCommand::new(
std::env::current_exe().context("failed to resolve current executable")?,
);
command
.arg("daemon")
.arg("--config")
.arg(config_path)
.arg("--iface")
.arg(&args.iface)
.arg("--mesh-refresh-interval-secs")
.arg(args.mesh_refresh_interval_secs.to_string())
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(stderr_log));
if let Some(network_id) = &args.network_id {
command.arg("--network-id").arg(network_id);
}
for participant in &args.participants {
command.arg("--participant").arg(participant);
}
let mut child = command
.spawn()
.context("failed to spawn daemonized connect process")?;
let pid = child.id();
for _ in 0..25 {
if let Some(status) = child
.try_wait()
.context("failed to verify daemon process state")?
{
let log_tail = read_daemon_log_tail(&log_file_path, 20);
return if log_tail.is_empty() {
Err(anyhow!(
"daemon process exited during startup with status {status}"
))
} else {
Err(anyhow!(
"daemon process exited during startup with status {status}\nlog tail:\n{log_tail}"
))
};
}
thread::sleep(Duration::from_millis(100));
}
let record = DaemonPidRecord {
pid,
config_path: config_path.display().to_string(),
started_at: unix_timestamp(),
};
let pid_file = daemon_pid_file_path(config_path);
write_daemon_pid_record(&pid_file, &record)?;
Ok(pid)
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub(crate) fn stop_existing_daemons_before_service_install(config_path: &Path) -> Result<()> {
stop_daemon(StopArgs {
config: Some(config_path.to_path_buf()),
timeout_secs: 5,
force: true,
})
}
pub(crate) fn read_daemon_log_tail(path: &Path, max_lines: usize) -> String {
let Ok(raw) = fs::read_to_string(path) else {
return String::new();
};
let mut lines = raw
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if lines.len() > max_lines {
lines.drain(0..(lines.len() - max_lines));
}
lines.join("\n")
}
#[cfg(unix)]
pub(crate) fn is_process_running(pid: u32) -> bool {
ProcessCommand::new("ps")
.arg("-p")
.arg(pid.to_string())
.arg("-o")
.arg("pid=")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| !String::from_utf8_lossy(&output.stdout).trim().is_empty())
.unwrap_or(false)
}
#[cfg(windows)]
pub(crate) fn is_process_running(pid: u32) -> bool {
let output = ProcessCommand::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.output();
let Ok(output) = output else {
return false;
};
if !output.status.success() {
return false;
}
tasklist_pids_from_output(&String::from_utf8_lossy(&output.stdout)).contains(&pid)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn is_process_running(_pid: u32) -> bool {
false
}
#[cfg(unix)]
pub(crate) fn daemon_pid_record_counts_as_running(pid: u32, config_path: &Path) -> bool {
if !is_process_running(pid) {
return false;
}
let output = ProcessCommand::new("ps")
.arg("-p")
.arg(pid.to_string())
.arg("-o")
.arg("stat=,command=")
.output();
let Ok(output) = output else {
return false;
};
if !output.status.success() {
return false;
}
daemon_pids_from_ps_output(&String::from_utf8_lossy(&output.stdout), config_path).contains(&pid)
}
#[cfg(windows)]
pub(crate) fn daemon_pid_record_counts_as_running(pid: u32, _config_path: &Path) -> bool {
is_process_running(pid)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn daemon_pid_record_counts_as_running(_pid: u32, _config_path: &Path) -> bool {
false
}
#[cfg(unix)]
pub(crate) fn find_daemon_pids_by_config(config_path: &Path) -> Vec<u32> {
let output = ProcessCommand::new("ps")
.arg("ax")
.arg("-o")
.arg("pid=,stat=,command=")
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
daemon_pids_from_ps_output(&String::from_utf8_lossy(&output.stdout), config_path)
}
#[cfg(windows)]
pub(crate) fn find_daemon_pids_by_config(config_path: &Path) -> Vec<u32> {
let output = ProcessCommand::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-CimInstance Win32_Process -Filter \"Name LIKE 'nvpn%.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
daemon_pids_from_windows_cim_json(&String::from_utf8_lossy(&output.stdout), config_path)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn find_daemon_pids_by_config(_config_path: &Path) -> Vec<u32> {
Vec::new()
}
#[cfg(any(unix, test))]
pub(crate) fn daemon_pids_from_ps_output(ps_output: &str, config_path: &Path) -> Vec<u32> {
let mut pids = Vec::new();
for line in ps_output.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() {
continue;
}
let mut parts = trimmed.split_whitespace();
let Some(pid_text) = parts.next() else {
continue;
};
let Some(second) = parts.next() else {
continue;
};
let Ok(pid) = pid_text.parse::<u32>() else {
continue;
};
let (stat, command) = if unix_ps_field_looks_like_stat(second) {
let Some((_, command)) = trimmed
.split_once(second)
.map(|(prefix, suffix)| (prefix, suffix.trim_start()))
else {
continue;
};
(second, command)
} else {
("", trimmed[pid_text.len()..].trim_start())
};
if !unix_process_stat_counts_as_running(stat) {
continue;
}
if daemon_command_matches_config(command, config_path) {
pids.push(pid);
}
}
pids.sort_unstable();
pids.dedup();
pids
}
#[cfg(any(unix, test))]
pub(crate) fn unix_process_stat_counts_as_running(stat: &str) -> bool {
let trimmed = stat.trim();
if trimmed.is_empty() {
return true;
}
let state = trimmed.chars().next().unwrap_or_default();
if matches!(state, 'Z' | 'X') {
return false;
}
!trimmed.contains('E')
}
#[cfg(any(unix, test))]
fn unix_ps_field_looks_like_stat(field: &str) -> bool {
let trimmed = field.trim();
!trimmed.is_empty()
&& trimmed.len() <= 8
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphabetic() || matches!(ch, '<' | '>' | '+' | '-' | '|' | ':'))
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn tasklist_pids_from_output(tasklist_output: &str) -> Vec<u32> {
let trimmed = tasklist_output.trim();
if trimmed.is_empty()
|| trimmed
.to_ascii_lowercase()
.contains("no tasks are running which match")
{
return Vec::new();
}
let mut pids = Vec::new();
for line in trimmed.lines() {
let line = line.trim();
if !(line.starts_with('"') && line.ends_with('"')) {
continue;
}
let inner = &line[1..line.len().saturating_sub(1)];
let mut fields = inner.split("\",\"");
let _image_name = fields.next();
let Some(pid_text) = fields.next() else {
continue;
};
let Ok(pid) = pid_text.parse::<u32>() else {
continue;
};
pids.push(pid);
}
pids.sort_unstable();
pids.dedup();
pids
}
#[cfg(windows)]
pub(crate) fn windows_nvpn_pids() -> Vec<u32> {
let output = ProcessCommand::new("tasklist")
.args(["/FI", "IMAGENAME eq nvpn.exe", "/FO", "CSV", "/NH"])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
tasklist_pids_from_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn recent_windows_daemon_pid_candidate(
state: Option<&DaemonRuntimeState>,
current_pid: u32,
nvpn_pids: &[u32],
now: u64,
) -> Option<u32> {
let state = state?;
if now.saturating_sub(state.updated_at) > WINDOWS_DAEMON_STATE_FRESHNESS_SECS {
return None;
}
let mut other_pids = nvpn_pids
.iter()
.copied()
.filter(|pid| *pid != current_pid)
.collect::<Vec<_>>();
other_pids.sort_unstable();
other_pids.dedup();
if other_pids.len() == 1 {
Some(other_pids[0])
} else {
None
}
}
pub(crate) fn daemon_candidate_pids(config_path: &Path, current_pid: u32) -> Result<Vec<u32>> {
let mut daemon_pids = find_daemon_pids_by_config(config_path);
let pid_file = daemon_pid_file_path(config_path);
if let Some(record) = read_daemon_pid_record(&pid_file)?
&& record.pid != current_pid
&& daemon_pid_record_counts_as_running(record.pid, config_path)
&& !daemon_pids.contains(&record.pid)
{
daemon_pids.push(record.pid);
}
#[cfg(windows)]
{
let state = read_daemon_state(&daemon_state_file_path(config_path))?;
if let Some(pid) = recent_windows_daemon_pid_candidate(
state.as_ref(),
current_pid,
&windows_nvpn_pids(),
unix_timestamp(),
) && !daemon_pids.contains(&pid)
{
daemon_pids.push(pid);
}
}
daemon_pids.retain(|pid| *pid != current_pid);
daemon_pids.sort_unstable();
daemon_pids.dedup();
Ok(daemon_pids)
}
pub(crate) fn daemon_command_matches_config(command: &str, config_path: &Path) -> bool {
let config_text = config_path.display().to_string();
let Some((prefix, _)) = command.split_once(" daemon ") else {
return false;
};
daemon_command_has_nvpn_executable_prefix(prefix)
&& !daemon_command_prefix_looks_like_shell_wrapper(prefix)
&& command.contains(" daemon ")
&& command.contains("--config")
&& command.contains(config_text.as_str())
}
fn daemon_command_has_nvpn_executable_prefix(prefix: &str) -> bool {
let trimmed = prefix.trim().trim_matches(|ch| ch == '"' || ch == '\'');
if trimmed.is_empty() {
return false;
}
let normalized = trimmed.replace('\\', "/");
if normalized == "nvpn"
|| normalized.ends_with("/nvpn")
|| normalized.eq_ignore_ascii_case("nvpn.exe")
|| normalized.to_ascii_lowercase().ends_with("/nvpn.exe")
{
return true;
}
#[cfg(any(target_os = "macos", test))]
{
if let Some(name) = normalized.rsplit('/').next()
&& (name == MACOS_SERVICE_LABEL
|| name
.strip_prefix(MACOS_SERVICE_LABEL)
.is_some_and(|rest| rest.starts_with('.')))
{
return true;
}
}
false
}
fn daemon_command_prefix_looks_like_shell_wrapper(prefix: &str) -> bool {
let trimmed = prefix.trim_start();
let lower = trimmed.to_ascii_lowercase();
lower.starts_with("bash ")
|| lower.starts_with("sh ")
|| lower.starts_with("zsh ")
|| lower.starts_with("dash ")
|| lower.starts_with("fish ")
|| lower.starts_with("cmd ")
|| lower.starts_with("powershell ")
|| lower.starts_with("pwsh ")
|| trimmed.contains(" -c ")
|| trimmed.contains(';')
|| trimmed.contains("&&")
|| trimmed.contains("||")
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn daemon_pids_from_windows_cim_json(cim_json: &str, config_path: &Path) -> Vec<u32> {
let trimmed = cim_json.trim();
if trimmed.is_empty() || trimmed == "null" {
return Vec::new();
}
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return Vec::new();
};
let entries = match parsed {
serde_json::Value::Array(entries) => entries,
serde_json::Value::Object(entry) => vec![serde_json::Value::Object(entry)],
_ => return Vec::new(),
};
let mut pids = Vec::new();
for entry in entries {
let Some(command) = entry.get("CommandLine").and_then(serde_json::Value::as_str) else {
continue;
};
let Some(pid) = entry
.get("ProcessId")
.and_then(serde_json::Value::as_u64)
.and_then(|pid| u32::try_from(pid).ok())
else {
continue;
};
if daemon_command_matches_config(command, config_path) {
pids.push(pid);
}
}
pids.sort_unstable();
pids.dedup();
pids
}
pub(crate) fn set_daemon_runtime_file_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
let permissions = fs::Permissions::from_mode(0o644);
fs::set_permissions(path, permissions).with_context(|| {
format!(
"failed to set daemon runtime file permissions on {}",
path.display()
)
})?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub(crate) fn set_private_cache_file_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| {
format!(
"failed to set daemon peer cache file permissions on {}",
path.display()
)
})?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub(crate) fn executable_fingerprint(path: &Path) -> Result<ExecutableFingerprint> {
let metadata = fs::metadata(path)
.with_context(|| format!("failed to stat executable {}", path.display()))?;
let modified_unix_nanos = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_nanos());
Ok(ExecutableFingerprint {
len: metadata.len(),
modified_unix_nanos,
})
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub(crate) fn current_executable_fingerprint() -> Result<(PathBuf, ExecutableFingerprint)> {
let executable = std::env::current_exe().context("failed to resolve current executable")?;
let executable = fs::canonicalize(&executable)
.with_context(|| format!("failed to canonicalize {}", executable.display()))?;
let fingerprint = executable_fingerprint(&executable)?;
Ok((executable, fingerprint))
}
pub(crate) fn service_supervisor_restart_due(
executable: &Path,
launched_fingerprint: &ExecutableFingerprint,
) -> Result<bool> {
Ok(executable_fingerprint(executable)? != *launched_fingerprint)
}
#[cfg(unix)]
pub(crate) fn send_signal(pid: u32, signal: &str) -> Result<()> {
if cfg!(not(unix)) {
return Err(anyhow!("daemon signal control is only supported on unix"));
}
let output = ProcessCommand::new("kill")
.arg(signal)
.arg(pid.to_string())
.output()
.with_context(|| format!("failed to execute kill {signal} {pid}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(anyhow!(
"kill {signal} {pid} failed\nstdout: {}\nstderr: {}",
stdout.trim(),
stderr.trim()
))
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_taskkill_pid(pid: u32) -> Result<()> {
let output = ProcessCommand::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output()
.with_context(|| format!("failed to execute taskkill /PID {pid} /F"))?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let details = format!("{}\n{}", stdout.trim(), stderr.trim())
.trim()
.to_string();
let lower = details.to_ascii_lowercase();
if lower.contains("not found") || lower.contains("no running instance") {
return Ok(());
}
Err(anyhow!(
"taskkill /PID {pid} /F failed\nstdout: {}\nstderr: {}",
stdout.trim(),
stderr.trim()
))
}
#[cfg(any(unix, test))]
pub(crate) fn kill_error_requires_control_fallback(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("operation not permitted") || lower.contains("permission denied")
}
pub(crate) fn run_ping(target: &str, count: u32, timeout_secs: u64) -> Result<()> {
let mut command = ProcessCommand::new("ping");
if cfg!(target_os = "windows") {
command
.arg("-n")
.arg(count.to_string())
.arg("-w")
.arg((timeout_secs.saturating_mul(1000)).to_string())
.arg(target);
} else {
command
.arg("-c")
.arg(count.to_string())
.arg("-W")
.arg(timeout_secs.to_string())
.arg(target);
}
let output = command
.output()
.with_context(|| format!("failed to execute ping for {target}"))?;
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
if !output.status.success() {
return Err(anyhow!("ping failed for {target}"));
}
Ok(())
}
pub(crate) fn resolve_ping_target(target: &str, peers: &[PeerAnnouncement]) -> Option<String> {
if target.parse::<IpAddr>().is_ok() {
return Some(target.to_string());
}
peers.iter().find_map(|peer| {
let tunnel_ip = strip_cidr(&peer.tunnel_ip);
if peer.node_id == target || peer.tunnel_ip == target || tunnel_ip == target {
Some(tunnel_ip.to_string())
} else {
None
}
})
}