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)?;
if let Err(error) = persist_desired_daemon_vpn_enabled_for_request(config_path, request) {
eprintln!(
"daemon: failed to persist desired VPN state in {}: {}",
config_path.display(),
error
);
}
project_daemon_vpn_enabled_request(config_path, request);
Ok(())
}
pub(crate) fn persist_desired_daemon_vpn_enabled_for_request(
config_path: &Path,
request: DaemonControlRequest,
) -> Result<Option<bool>> {
let vpn_enabled = match request {
DaemonControlRequest::Pause => false,
DaemonControlRequest::Resume => true,
DaemonControlRequest::Reload | DaemonControlRequest::Stop => return Ok(None),
};
persist_desired_daemon_vpn_enabled(config_path, vpn_enabled)?;
Ok(Some(vpn_enabled))
}
pub(crate) fn persist_desired_daemon_vpn_enabled(
config_path: &Path,
vpn_enabled: bool,
) -> Result<bool> {
let mut app = load_or_default_config(config_path)?;
persist_desired_daemon_vpn_enabled_in_config(&mut app, config_path, vpn_enabled)
}
pub(crate) fn persist_desired_daemon_vpn_enabled_in_config(
app: &mut AppConfig,
config_path: &Path,
vpn_enabled: bool,
) -> Result<bool> {
if app.autoconnect == vpn_enabled {
return Ok(false);
}
app.autoconnect = vpn_enabled;
app.ensure_defaults();
maybe_autoconfigure_node(app);
app.save(config_path)?;
Ok(true)
}
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 mut config = AppConfig::load(source_path)
.with_context(|| format!("failed to load source config {}", source_path.display()))?;
config.ensure_defaults();
maybe_autoconfigure_node(&mut config);
config
.save(&staged_path)
.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);
let _ = AppConfig::delete_persisted_secrets_for_path(&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(false)?;
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(())
}