#[cfg(any(target_os = "macos", test))]
fn macos_effective_uid() -> u32 {
#[cfg(test)]
{
let override_uid = TEST_MACOS_EUID_OVERRIDE.load(Ordering::Relaxed);
if override_uid != TEST_MACOS_EUID_SENTINEL {
return override_uid;
}
}
#[cfg(target_os = "macos")]
{
unsafe { libc::geteuid() as u32 }
}
#[cfg(not(target_os = "macos"))]
{
0
}
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn ensure_macos_connect_privileges(config_path: &Path) -> Result<()> {
if macos_effective_uid() == 0 {
return Ok(());
}
Err(anyhow!(
"macOS tunnel setup requires admin privileges (did you run with sudo?); run `sudo nvpn start --connect --config {}` for a one-off session or `sudo nvpn service install --config {}` to use the launchd service",
config_path.display(),
config_path.display()
))
}
async fn start_session(args: StartArgs) -> Result<()> {
let config_path = args.config.clone().unwrap_or_else(default_config_path);
let (app, _network_id) = load_config_with_overrides(
&config_path,
args.network_id.clone(),
args.devices.clone(),
)?;
if args.connect {
persist_desired_daemon_vpn_enabled(&config_path, true)?;
} else if args.no_connect {
persist_desired_daemon_vpn_enabled(&config_path, false)?;
}
let should_connect = if args.connect {
true
} else if args.no_connect {
false
} else {
app.autoconnect
};
if !should_connect {
println!(
"start: autoconnect is disabled; not starting a session (pass --connect to override)"
);
return Ok(());
}
let connect_args = ConnectArgs {
config: Some(config_path.clone()),
network_id: args.network_id,
devices: args.devices,
iface: args.iface,
mesh_refresh_interval_secs: args.mesh_refresh_interval_secs,
};
if args.daemon {
let status = daemon_status(&config_path)?;
if status.running {
return Err(anyhow!(
"daemon already running with pid {}",
status.pid.unwrap_or_default()
));
}
let pid = spawn_daemon_process(&connect_args, &config_path)?;
println!("daemon started: pid {pid}");
println!("pid_file: {}", status.pid_file.display());
println!("log_file: {}", status.log_file.display());
return Ok(());
}
connect_vpn(connect_args).await
}
fn stop_daemon(args: StopArgs) -> Result<()> {
let config_path = args.config.unwrap_or_else(default_config_path);
let status = daemon_status(&config_path)?;
let current_pid = std::process::id();
let daemon_pids = daemon_candidate_pids(&config_path, current_pid)?;
if daemon_pids.is_empty() {
return finish_stop_daemon(&config_path, &status, false);
}
#[cfg(target_os = "windows")]
let requested_control_stop = {
request_daemon_stop(&config_path)?;
true
};
#[cfg(not(target_os = "windows"))]
let mut requested_control_stop = false;
#[cfg(not(target_os = "windows"))]
for pid in &daemon_pids {
match send_signal(*pid, "-TERM") {
Ok(()) => {}
Err(error) if kill_error_requires_control_fallback(&error.to_string()) => {
if !requested_control_stop {
request_daemon_stop(&config_path)?;
requested_control_stop = true;
}
}
Err(error) => return Err(error),
}
}
let timeout = Duration::from_secs(args.timeout_secs.max(1));
let started = std::time::Instant::now();
while started.elapsed() < timeout {
if daemon_candidate_pids(&config_path, current_pid)?.is_empty() {
return finish_stop_daemon(&config_path, &status, true);
}
thread::sleep(Duration::from_millis(120));
}
if args.force {
for pid in daemon_candidate_pids(&config_path, current_pid)? {
#[cfg(target_os = "windows")]
windows_taskkill_pid(pid)?;
#[cfg(not(target_os = "windows"))]
if let Err(error) = send_signal(pid, "-KILL")
&& !kill_error_requires_control_fallback(&error.to_string())
{
return Err(error);
}
}
thread::sleep(Duration::from_millis(120));
}
if requested_control_stop {
#[cfg(not(target_os = "windows"))]
request_daemon_stop(&config_path)?;
let started = std::time::Instant::now();
while started.elapsed() < timeout {
if daemon_candidate_pids(&config_path, current_pid)?.is_empty() {
return finish_stop_daemon(&config_path, &status, true);
}
thread::sleep(Duration::from_millis(120));
}
}
let remaining = daemon_candidate_pids(&config_path, current_pid)?;
if !remaining.is_empty() {
let hint = stop_daemon_remaining_hint(&config_path, &remaining, requested_control_stop);
return Err(anyhow!(
"failed to stop daemon(s) for {}; remaining pid(s): {}; {hint}",
config_path.display(),
remaining
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
finish_stop_daemon(&config_path, &status, true)
}
fn stop_daemon_remaining_hint(
#[allow(unused_variables)] config_path: &Path,
#[allow(unused_variables)] remaining: &[u32],
requested_control_stop: bool,
) -> String {
#[cfg(target_os = "macos")]
if let Ok(service_status) = service_management::query_service_status(config_path)
&& let Some(hint) = macos_stop_daemon_hint_from_service_status(&service_status, remaining)
{
return hint;
}
if requested_control_stop {
"daemon ignored local stop request; likely an older daemon binary is still running. perform one elevated stop (e.g. sudo nvpn stop --force --config <config>) to migrate".to_string()
} else {
"try --force".to_string()
}
}
#[cfg(any(target_os = "macos", test))]
fn macos_stop_daemon_hint_from_service_status(
service_status: &ServiceStatusView,
remaining: &[u32],
) -> Option<String> {
if !(service_status.supported
&& service_status.installed
&& service_status.loaded
&& service_status.running)
{
return None;
}
let pid = service_status.pid?;
if !remaining.contains(&pid) {
return None;
}
Some(format!(
"daemon is managed by launchd service {}; it may be getting restarted automatically. use sudo nvpn service disable --config <config> to stop it completely, or sudo nvpn service enable --config <config> to restart it onto the current binary",
service_status.label
))
}
fn finish_stop_daemon(config_path: &Path, status: &DaemonStatus, was_running: bool) -> Result<()> {
let repaired = repair_saved_network_state(config_path);
let _ = fs::remove_file(&status.pid_file);
let _ = fs::remove_file(daemon_control_file_path(config_path));
match repaired {
Ok(true) if was_running => println!("daemon stopped; repaired network state"),
Ok(true) => println!("daemon: not running; repaired network state"),
Ok(false) if was_running => println!("daemon stopped"),
Ok(false) => println!("daemon: not running"),
Err(error) => {
return Err(anyhow!(
"{} but failed to repair network state: {error}",
if was_running {
"daemon stopped"
} else {
"daemon is not running"
}
));
}
}
Ok(())
}
fn repair_network(args: RepairNetworkArgs) -> Result<()> {
let config_path = args.config.unwrap_or_else(default_config_path);
if let Some(pid) = daemon_candidate_pids(&config_path, std::process::id())?
.into_iter()
.next()
{
return Err(anyhow!(
"daemon is still running with pid {pid}; stop it before repairing network state"
));
}
if repair_saved_network_state(&config_path)? {
println!("network state repaired");
} else {
println!("network state already clean");
}
Ok(())
}
fn reload_daemon(args: ReloadArgs) -> Result<()> {
let config_path = args.config.unwrap_or_else(default_config_path);
let status = daemon_status(&config_path)?;
if !status.running {
println!("daemon: not running");
return Ok(());
}
request_daemon_reload(&config_path)?;
wait_for_daemon_control_ack(
&config_path,
daemon_control_ack_timeout(DaemonControlRequest::Reload),
)?;
println!("daemon reload requested");
Ok(())
}
pub(crate) fn daemon_control_ack_timeout(request: DaemonControlRequest) -> Duration {
if matches!(
request,
DaemonControlRequest::Pause | DaemonControlRequest::Resume
) {
#[cfg(target_os = "macos")]
{
return Duration::from_secs(15);
}
}
Duration::from_secs(10)
}
pub(crate) fn daemon_control_result_timeout(request: DaemonControlRequest) -> Duration {
if matches!(
request,
DaemonControlRequest::Pause | DaemonControlRequest::Resume
) {
#[cfg(target_os = "macos")]
{
return Duration::from_secs(30);
}
}
Duration::from_secs(15)
}
#[cfg(test)]
pub(crate) fn daemon_control_vpn_transition_timeout(request: DaemonControlRequest) -> Duration {
if matches!(
request,
DaemonControlRequest::Pause | DaemonControlRequest::Resume
) {
#[cfg(target_os = "macos")]
{
return Duration::from_secs(30);
}
#[cfg(not(target_os = "macos"))]
{
return Duration::from_secs(2);
}
}
Duration::ZERO
}
fn control_daemon(args: ControlArgs, request: DaemonControlRequest) -> Result<()> {
let config_path = args.config.unwrap_or_else(default_config_path);
let status = daemon_status(&config_path)?;
if !status.running {
persist_desired_daemon_vpn_enabled_for_request(&config_path, request)?;
println!("daemon: not running");
return Ok(());
}
write_daemon_control_request(&config_path, request)?;
match request {
DaemonControlRequest::Pause | DaemonControlRequest::Resume => {}
DaemonControlRequest::Reload | DaemonControlRequest::Stop => {
wait_for_daemon_control_ack(&config_path, daemon_control_ack_timeout(request))?;
}
}
match request {
DaemonControlRequest::Pause => println!("daemon pause requested"),
DaemonControlRequest::Resume => println!("daemon resume requested"),
DaemonControlRequest::Reload => println!("daemon reload requested"),
DaemonControlRequest::Stop => println!("daemon stop requested"),
}
Ok(())
}
fn daemon_status(config_path: &Path) -> Result<DaemonStatus> {
let pid_file = daemon_pid_file_path(config_path);
let log_file = daemon_log_file_path(config_path);
let state_file = daemon_state_file_path(config_path);
let pid_record = read_daemon_pid_record(&pid_file)?;
let pid_from_record = pid_record.as_ref().map(|record| record.pid);
let running_pid = daemon_candidate_pids(config_path, std::process::id())?
.into_iter()
.next();
let pid = running_pid.or(pid_from_record);
let state = read_daemon_state(&state_file)?;
let running = running_pid.is_some() || daemon_state_file_counts_as_running(state.as_ref());
if let Some(pid) = running_pid
&& pid_from_record != Some(pid)
{
let refreshed = DaemonPidRecord {
pid,
config_path: config_path.display().to_string(),
started_at: unix_timestamp(),
};
let _ = write_daemon_pid_record(&pid_file, &refreshed);
}
Ok(DaemonStatus {
running,
pid,
pid_file,
log_file,
state_file,
state,
})
}
fn daemon_state_file_counts_as_running(state: Option<&DaemonRuntimeState>) -> bool {
if !daemon_state_file_status_mode_enabled() {
return false;
}
let Some(state) = state else {
return false;
};
daemon_state_is_fresh(state, unix_timestamp(), daemon_state_running_max_age_secs())
}
fn daemon_state_file_status_mode_enabled() -> bool {
std::env::var(DAEMON_STATUS_MODE_ENV)
.map(|value| {
let normalized = value.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
DAEMON_STATUS_MODE_STATE_FILE | "1" | "true" | "yes"
)
})
.unwrap_or(false)
}
fn daemon_state_running_max_age_secs() -> u64 {
std::env::var(DAEMON_STATE_RUNNING_MAX_AGE_SECS_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_DAEMON_STATE_RUNNING_MAX_AGE_SECS)
}
fn daemon_state_is_fresh(state: &DaemonRuntimeState, now: u64, max_age_secs: u64) -> bool {
if state.updated_at == 0 {
return false;
}
if state.updated_at > now {
return state.updated_at - now <= DAEMON_STATE_RUNNING_MAX_FUTURE_SKEW_SECS;
}
now - state.updated_at <= max_age_secs
}
fn daemon_status_json_value(status: &DaemonStatus) -> serde_json::Value {
json!({
"running": status.running,
"pid": status.pid,
"pid_file": status.pid_file,
"log_file": status.log_file,
"state_file": status.state_file,
"state": visible_daemon_state_for_status(status.running, status.state.as_ref()),
})
}
fn status_endpoint(app: &AppConfig, daemon: &DaemonStatus) -> String {
daemon
.state
.as_ref()
.and_then(|state| {
let endpoint = state.advertised_endpoint.trim();
(!endpoint.is_empty()).then(|| endpoint.to_string())
})
.unwrap_or_else(|| app.node.endpoint.clone())
}
fn status_listen_port(app: &AppConfig, daemon: &DaemonStatus) -> u16 {
daemon
.state
.as_ref()
.and_then(|state| (state.listen_port > 0).then_some(state.listen_port))
.unwrap_or(app.node.listen_port)
}
fn strip_cidr(value: &str) -> &str {
value.split('/').next().unwrap_or(value)
}
fn expected_peer_count(config: &AppConfig) -> usize {
let participants = config.participant_pubkeys_hex();
if participants.is_empty() {
return 0;
}
let mut expected = participants.len();
if let Ok(own_pubkey) = config.own_nostr_pubkey_hex()
&& participants
.iter()
.any(|participant| participant == &own_pubkey)
{
expected = expected.saturating_sub(1);
}
expected
}
fn format_probe_state(state: ProbeState) -> &'static str {
match state {
ProbeState::Available => "available",
ProbeState::Unavailable => "unavailable",
ProbeState::Unsupported => "unsupported",
ProbeState::Error => "error",
ProbeState::Unknown => "unknown",
}
}