#[cfg(not(unix))]
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use qrcode::QrCode;
use qrcode::render::unicode::Dense1x2;
use nostr_vpn_core::config::AppConfig;
#[cfg(not(unix))]
use crate::maybe_reload_running_daemon;
use crate::{
DaemonRuntimeState, JoinRequestArgs, daemon_state_file_path, default_config_path,
read_daemon_state, unix_timestamp,
};
pub(crate) const JOIN_REQUEST_LINK_PREFIX: &str = "nvpn://join-request/";
const REQUEST_REACHABILITY_MAX_AGE_SECS: u64 = crate::DAEMON_STATE_PERSIST_INTERVAL_SECS * 3;
#[cfg(all(test, not(unix)))]
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);
#[cfg(unix)]
let uri = crate::join_request_ipc::request_daemon_join_request_link(&config_path, args.reset)
.await
.context("the nVPN daemon must be running to create an ephemeral join request")?;
#[cfg(not(unix))]
let app = ensure_pending_join_request_and_reload(
&config_path,
args.reset,
maybe_reload_running_daemon,
)?;
#[cfg(not(unix))]
let uri = app
.pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
.context("config has no valid pending device-approval request")?;
if args.no_qr {
println!("{uri}");
} else {
print!("{}", render_pairing_output(&uri)?);
}
let state_path = daemon_state_file_path(&config_path);
let 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() => {
#[cfg(unix)]
if AppConfig::load(&config_path)
.is_ok_and(|app| app.active_network_has_confirmed_local_identity())
{
println!("Join request accepted.");
return Ok(());
}
#[cfg(not(unix))]
let app = AppConfig::load(&config_path)
.with_context(|| format!("failed to reload {}", config_path.display()))?;
#[cfg(not(unix))]
if app.active_network_has_confirmed_local_identity() {
println!("Join approved for network {}.", app.effective_network_id());
return Ok(());
}
}
}
}
}
#[cfg(not(unix))]
fn ensure_pending_join_request_and_reload(
config_path: &Path,
reset: bool,
reload_running_daemon: impl FnOnce(&Path),
) -> Result<AppConfig> {
let app = ensure_pending_join_request(config_path, reset)?;
if !app.active_network_has_confirmed_local_identity() {
reload_running_daemon(config_path);
}
Ok(app)
}
#[cfg(not(unix))]
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 {
request_reachability_at(state, unix_timestamp())
}
fn request_reachability_at(state: Option<&DaemonRuntimeState>, now: u64) -> RequestReachability {
let Some(state) = state else {
return RequestReachability::DaemonUnavailable;
};
if now.saturating_sub(state.updated_at) > REQUEST_REACHABILITY_MAX_AGE_SECS {
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 {
#[cfg(unix)]
use std::time::SystemTime;
#[cfg(not(unix))]
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
#[test]
fn reachability_stays_stable_across_the_daemon_state_write_interval() {
let state = DaemonRuntimeState {
updated_at: 100,
fips_other_peer_count: 1,
..DaemonRuntimeState::default()
};
assert_eq!(
request_reachability_at(Some(&state), 105),
RequestReachability::FipsReachable,
"the five-second daemon state cadence must not flap to unavailable"
);
}
#[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")));
}
#[cfg(unix)]
#[tokio::test]
async fn blocking_wait_requests_once_then_observes_persisted_approval() {
let nonce = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos();
let directory = std::path::PathBuf::from("/tmp").join(format!(
"nvw-{}-{:x}",
std::process::id(),
nonce & 0xffff_ffff
));
std::fs::create_dir(&directory).expect("create test directory");
let config = directory.join("config.toml");
let (requests, mut received) = tokio::sync::mpsc::unbounded_channel();
let server = crate::join_request_ipc::JoinRequestIpcServer::spawn(&config, requests)
.expect("start join request IPC server");
let waiter = tokio::spawn(run_join_request(JoinRequestArgs {
config: Some(config.clone()),
no_wait: false,
no_qr: true,
reset: false,
}));
let request = tokio::time::timeout(Duration::from_secs(2), received.recv())
.await
.expect("initial IPC request timeout")
.expect("initial IPC request");
assert!(!request.reset);
request
.response
.send(Ok(format!("{JOIN_REQUEST_LINK_PREFIX}test")))
.expect("answer initial IPC request");
tokio::time::sleep(Duration::from_millis(750)).await;
assert!(
received.try_recv().is_err(),
"waiting must observe config state instead of polling join-request IPC"
);
let mut approved = AppConfig::generated_without_networks();
let network_id = approved.add_owned_network("Approved network");
let own_pubkey = approved.own_nostr_pubkey_hex().expect("own public key");
approved
.network_by_id_mut(&network_id)
.expect("owned network")
.devices
.push(own_pubkey);
approved
.set_network_enabled(&network_id, true)
.expect("enable owned network");
approved.save(&config).expect("persist approval");
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.expect("blocking wait did not finish")
.expect("wait task panicked")
.expect("blocking wait failed");
drop(server);
AppConfig::delete_persisted_secrets_for_path(&config).expect("delete test secrets");
let _ = std::fs::remove_dir_all(directory);
}
#[cfg(not(unix))]
#[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);
}
#[cfg(not(unix))]
#[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);
}
#[cfg(not(unix))]
#[test]
fn pending_request_reload_is_requested_after_the_request_is_persisted() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"nvpn-join-request-reload-{}-{nonce}.toml",
std::process::id()
));
let mut reloads = 0;
let app = ensure_pending_join_request_and_reload(&path, false, |reload_path| {
reloads += 1;
assert_eq!(reload_path, path);
let persisted = AppConfig::load(reload_path).expect("persisted pending request");
assert!(
persisted
.pending_nostr_join_request_link(JOIN_REQUEST_LINK_PREFIX)
.is_ok()
);
})
.expect("prepare pending request");
assert_eq!(reloads, 1);
assert!(!app.active_network_has_confirmed_local_identity());
AppConfig::delete_persisted_secrets_for_path(&path).expect("delete secrets");
let _ = std::fs::remove_file(path);
}
#[cfg(not(unix))]
#[test]
fn approved_request_does_not_reload_the_daemon() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"nvpn-approved-join-request-reload-{}-{nonce}.toml",
std::process::id()
));
let mut approved = AppConfig::generated_without_networks();
let network_id = approved.add_owned_network("Approved network");
let own_pubkey = approved.own_nostr_pubkey_hex().expect("own public key");
approved
.network_by_id_mut(&network_id)
.expect("owned network")
.devices
.push(own_pubkey);
approved
.set_network_enabled(&network_id, true)
.expect("enable owned network");
assert!(approved.active_network_has_confirmed_local_identity());
approved.save(&path).expect("save approved config");
let mut reloads = 0;
let app = ensure_pending_join_request_and_reload(&path, false, |_| reloads += 1)
.expect("load approved request state");
assert!(app.active_network_has_confirmed_local_identity());
assert_eq!(reloads, 0);
AppConfig::delete_persisted_secrets_for_path(&path).expect("delete secrets");
let _ = std::fs::remove_file(path);
}
}