nvpn 4.0.96

CLI and daemon for Nostr VPN private mesh networks
Documentation
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use qrcode::QrCode;
use qrcode::render::unicode::Dense1x2;

use nostr_vpn_core::config::AppConfig;

use crate::{
    DaemonRuntimeState, JoinRequestArgs, daemon_state_file_path, default_config_path,
    read_daemon_state, unix_timestamp,
};

const JOIN_REQUEST_LINK_PREFIX: &str = "nvpn://join-request/";
const WEBVM_PAIRING_URI_PATH: &str = "/run/webvm/join-request";
const WEBVM_PAIRING_URI_WAIT: Duration = Duration::from_secs(5);

#[cfg(test)]
pub(crate) fn pending_pairing_uri(config_path: &Path) -> Result<String> {
    let app = AppConfig::load(config_path)
        .with_context(|| format!("failed to load {}", config_path.display()))?;
    app.pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
        .context("config has no valid pending device-approval request")
}

pub(crate) fn render_pairing_output(uri: &str) -> Result<String> {
    let code = QrCode::new(uri.as_bytes()).context("failed to encode pairing QR code")?;
    let qr = code.render::<Dense1x2>().quiet_zone(true).build();
    Ok(format!("{qr}\n\n{uri}\n"))
}

pub(crate) async fn run_join_request(args: JoinRequestArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let app = ensure_pending_join_request(&config_path, args.reset)?;
    if app.active_network_has_confirmed_local_identity() {
        println!(
            "Already approved for network {}.",
            app.effective_network_id()
        );
        return Ok(());
    }

    let base_uri = app
        .pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
        .context("config has no valid pending device-approval request")?;
    let uri = pairing_uri_for_output(&base_uri).await?;
    if args.no_qr {
        println!("{uri}");
    } else {
        print!("{}", render_pairing_output(&uri)?);
    }

    let state_path = daemon_state_file_path(&config_path);
    let mut reachability = request_reachability(read_daemon_state(&state_path)?.as_ref());
    println!("{}", reachability.message());
    if args.no_wait {
        return Ok(());
    }
    println!("Waiting for an admin to approve this join request (Ctrl-C to stop waiting).");

    let mut poll = tokio::time::interval(Duration::from_millis(500));
    poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
    loop {
        tokio::select! {
            result = tokio::signal::ctrl_c() => {
                result.context("failed to wait for Ctrl-C")?;
                println!("Stopped waiting; the existing join request remains valid.");
                return Ok(());
            }
            _ = poll.tick() => {
                let app = AppConfig::load(&config_path)
                    .with_context(|| format!("failed to reload {}", config_path.display()))?;
                if app.active_network_has_confirmed_local_identity() {
                    println!("Join approved for network {}.", app.effective_network_id());
                    return Ok(());
                }
                let next = request_reachability(read_daemon_state(&state_path)?.as_ref());
                if next != reachability {
                    reachability = next;
                    println!("{}", reachability.message());
                }
            }
        }
    }
}

async fn pairing_uri_for_output(base_uri: &str) -> Result<String> {
    let webvm_directory = Path::new(WEBVM_PAIRING_URI_PATH)
        .parent()
        .expect("WebVM pairing URI has a parent");
    if !webvm_directory.is_dir() {
        return Ok(base_uri.to_string());
    }
    let deadline = tokio::time::Instant::now() + WEBVM_PAIRING_URI_WAIT;
    loop {
        if let Ok(candidate) = std::fs::read_to_string(WEBVM_PAIRING_URI_PATH) {
            let candidate = candidate.trim();
            if candidate
                .split_once('?')
                .is_some_and(|(request, route)| request == base_uri && route.starts_with("r="))
            {
                return Ok(candidate.to_string());
            }
        }
        if tokio::time::Instant::now() >= deadline {
            return Err(anyhow!(
                "WebVM browser FIPS return route is not ready; retry in a moment"
            ));
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
}

fn ensure_pending_join_request(config_path: &Path, reset: bool) -> Result<AppConfig> {
    let exists = config_path
        .try_exists()
        .with_context(|| format!("failed to inspect {}", config_path.display()))?;
    let mut app = if exists {
        AppConfig::load(config_path)
            .with_context(|| format!("failed to load {}", config_path.display()))?
    } else {
        AppConfig::generated_without_networks()
    };
    app.ensure_defaults();
    if app.active_network_has_confirmed_local_identity() {
        if reset {
            return Err(anyhow::anyhow!(
                "cannot reset a join request after this device has been approved"
            ));
        }
        return Ok(app);
    }
    if reset {
        app.clear_pending_nostr_join_request();
    }
    let changed = app.ensure_pending_nostr_join_request(unix_timestamp())?;
    if !exists || changed || reset {
        app.save(config_path)
            .with_context(|| format!("failed to save {}", config_path.display()))?;
    }
    Ok(app)
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestReachability {
    DaemonUnavailable,
    NoFipsPeers,
    FipsReachable,
}

impl RequestReachability {
    fn message(self) -> &'static str {
        match self {
            Self::DaemonUnavailable => {
                "nVPN daemon status is unavailable; the join request is still ready to share."
            }
            Self::NoFipsPeers => {
                "No active FIPS connections; an admin cannot deliver approval yet. The join request is still ready to share."
            }
            Self::FipsReachable => {
                "FIPS connection active; approval can be delivered immediately after an admin accepts."
            }
        }
    }
}

fn request_reachability(state: Option<&DaemonRuntimeState>) -> RequestReachability {
    let Some(state) = state else {
        return RequestReachability::DaemonUnavailable;
    };
    if unix_timestamp().saturating_sub(state.updated_at) > 4 {
        return RequestReachability::DaemonUnavailable;
    }
    let connected =
        state.fips_other_peer_count > 0 || state.peers.iter().any(|peer| peer.reachable);
    if connected {
        RequestReachability::FipsReachable
    } else {
        RequestReachability::NoFipsPeers
    }
}

#[cfg(test)]
mod tests {
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::*;

    #[test]
    fn terminal_output_contains_dense_qr_and_exact_uri() {
        let uri = "nvpn://join-request/eyJkZXZpY2VBcHBLZXlOcHViIjoibnB1YjE";
        let output = render_pairing_output(uri).expect("render pairing output");

        assert!(output.lines().any(|line| line.contains('')));
        assert!(output.lines().any(|line| line.contains('')));
        assert_eq!(output.lines().filter(|line| *line == uri).count(), 1);
        assert!(output.ends_with(&format!("\n\n{uri}\n")));
    }

    #[test]
    fn reads_the_canonical_pending_bootstrap_from_config() {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "nvpn-pairing-qr-{}-{nonce}.toml",
            std::process::id()
        ));
        let mut app = AppConfig::generated();
        app.ensure_pending_nostr_join_request(1_789_000_000)
            .expect("pending request");
        app.save(&path).expect("save config");

        let expected = app
            .pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
            .expect("expected URI");
        assert_eq!(pending_pairing_uri(&path).expect("loaded URI"), expected);

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn pending_request_is_reused_until_explicit_reset() {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "nvpn-join-request-reset-{}-{nonce}.toml",
            std::process::id()
        ));

        let first = ensure_pending_join_request(&path, false).expect("first request");
        let first_uri = first
            .pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
            .expect("first URI");
        let reused = ensure_pending_join_request(&path, false).expect("reused request");
        let reused_uri = reused
            .pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
            .expect("reused URI");
        let reset = ensure_pending_join_request(&path, true).expect("reset request");
        let reset_uri = reset
            .pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
            .expect("reset URI");

        assert_eq!(first_uri, reused_uri);
        assert_ne!(first_uri, reset_uri);
        AppConfig::delete_persisted_secrets_for_path(&path).expect("delete secrets");
        let _ = std::fs::remove_file(path);
    }
}