nvpn 4.1.7

CLI and daemon for Nostr VPN private mesh networks
Documentation
use super::*;

pub(super) fn daemon_termination_wait()
-> Result<std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>> {
    #[cfg(unix)]
    {
        let mut signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .context("failed to install SIGTERM handler")?;
        Ok(Box::pin(async move {
            let _ = signal.recv().await;
        }))
    }
    #[cfg(not(unix))]
    Ok(Box::pin(std::future::pending()))
}

pub(super) struct DaemonVpnShutdown<'a> {
    pub(super) port_mapping_runtime: &'a mut PortMappingRuntime,
    pub(super) fips_tunnel_runtime: Option<crate::fips_private_mesh::FipsPrivateTunnelRuntime>,
    pub(super) tunnel_runtime: &'a mut CliTunnelRuntime,
    pub(super) config_path: &'a Path,
    pub(super) state_file: &'a Path,
    pub(super) pid_file: &'a Path,
    pub(super) expected_peers: usize,
    pub(super) network_snapshot: &'a crate::diagnostics::NetworkSnapshot,
    pub(super) network_changed_at: Option<u64>,
    pub(super) captive_portal: Option<bool>,
}

trait DaemonShutdownOwnershipActions {
    fn write_terminal_state(&mut self) -> Result<()>;
    fn clear_control_ready(&mut self);
    fn remove_pid_record(&mut self);
}

struct SystemDaemonShutdownOwnership<'a> {
    config_path: &'a Path,
    state_file: &'a Path,
    pid_file: &'a Path,
    final_state: &'a DaemonRuntimeState,
}

impl DaemonShutdownOwnershipActions for SystemDaemonShutdownOwnership<'_> {
    fn write_terminal_state(&mut self) -> Result<()> {
        write_daemon_state(self.state_file, self.final_state)
            .context("failed to write terminal daemon state")
    }

    fn clear_control_ready(&mut self) {
        clear_daemon_control_ready(self.config_path);
    }

    fn remove_pid_record(&mut self) {
        remove_current_daemon_pid_record(self.pid_file);
    }
}

fn finalize_daemon_shutdown_ownership(
    actions: &mut impl DaemonShutdownOwnershipActions,
    mut failures: Vec<String>,
) -> Result<()> {
    if let Err(error) = actions.write_terminal_state() {
        failures.push(format!(
            "failed to persist terminal daemon state: {error:#}"
        ));
    }
    actions.clear_control_ready();
    actions.remove_pid_record();
    if failures.is_empty() {
        Ok(())
    } else {
        Err(anyhow!("{}", failures.join("; ")))
    }
}

pub(super) async fn shutdown_daemon_vpn(shutdown: DaemonVpnShutdown<'_>) -> Result<()> {
    let mut failures = Vec::new();
    let mut preserve_fips_cleanup_ownership = false;
    shutdown.port_mapping_runtime.stop().await;
    if let Some(runtime) = shutdown.fips_tunnel_runtime {
        if let Err(error) = stop_fips_private_tunnel_runtime(shutdown.config_path, runtime).await {
            eprintln!("daemon: failed to stop FIPS private mesh: {error}");
            failures.push(error.to_string());
            preserve_fips_cleanup_ownership = true;
        }
    } else if let Err(persist_error) =
        persist_fips_daemon_network_cleanup_state(shutdown.config_path, None)
    {
        failures.push(format!(
            "failed to persist pending FIPS cleanup ownership: {persist_error:#}"
        ));
    }
    shutdown.tunnel_runtime.stop();
    if !preserve_fips_cleanup_ownership
        && let Err(error) =
            persist_daemon_network_cleanup_state(shutdown.config_path, shutdown.tunnel_runtime)
    {
        eprintln!("daemon: failed to clear network cleanup state: {error}");
        failures.push(format!("failed to clear network cleanup state: {error:#}"));
    }
    let network = shutdown
        .network_snapshot
        .summary(shutdown.network_changed_at, shutdown.captive_portal);
    let final_state = if failures.is_empty() {
        disconnected_daemon_runtime_state(shutdown.expected_peers, &network)
    } else {
        cleanup_failed_daemon_runtime_state(shutdown.expected_peers, &network, &failures)
    };
    finalize_daemon_shutdown_ownership(
        &mut SystemDaemonShutdownOwnership {
            config_path: shutdown.config_path,
            state_file: shutdown.state_file,
            pid_file: shutdown.pid_file,
            final_state: &final_state,
        },
        failures,
    )
}

pub(super) fn finish_daemon_vpn_shutdown(
    terminal_error: Option<anyhow::Error>,
    shutdown_result: Result<()>,
) -> Result<()> {
    match (terminal_error, shutdown_result) {
        (None, result) => result,
        (Some(error), Ok(())) => Err(error),
        (Some(error), Err(shutdown_error)) => Err(anyhow!(
            "{error:#}; daemon shutdown also failed: {shutdown_error:#}"
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Default)]
    struct FakeShutdownOwnership {
        events: Vec<&'static str>,
        fail_state_write: bool,
    }

    impl DaemonShutdownOwnershipActions for FakeShutdownOwnership {
        fn write_terminal_state(&mut self) -> Result<()> {
            self.events.push("write-terminal");
            if self.fail_state_write {
                Err(anyhow!("synthetic state write failure"))
            } else {
                Ok(())
            }
        }

        fn clear_control_ready(&mut self) {
            self.events.push("clear-control");
        }

        fn remove_pid_record(&mut self) {
            self.events.push("remove-pid");
        }
    }

    #[test]
    fn failed_network_cleanup_writes_terminal_state_then_clears_liveness_markers() {
        let mut actions = FakeShutdownOwnership::default();
        let error = finalize_daemon_shutdown_ownership(
            &mut actions,
            vec!["synthetic network cleanup failure".to_string()],
        )
        .expect_err("cleanup failure must remain visible");

        assert!(format!("{error:#}").contains("synthetic network cleanup failure"));
        assert_eq!(
            actions.events,
            vec!["write-terminal", "clear-control", "remove-pid"],
            "the cleanup obligation remains durable, but an exited process must not retain \
             stale liveness markers"
        );
    }

    #[test]
    fn disconnected_state_must_persist_before_ownership_markers_are_removed() {
        let mut actions = FakeShutdownOwnership {
            fail_state_write: true,
            ..FakeShutdownOwnership::default()
        };
        finalize_daemon_shutdown_ownership(&mut actions, Vec::new())
            .expect_err("state persistence failure");
        assert_eq!(
            actions.events,
            vec!["write-terminal", "clear-control", "remove-pid"]
        );

        let mut actions = FakeShutdownOwnership::default();
        finalize_daemon_shutdown_ownership(&mut actions, Vec::new()).expect("clean shutdown");
        assert_eq!(
            actions.events,
            vec!["write-terminal", "clear-control", "remove-pid"]
        );
    }
}