nvpn 4.1.13

CLI and daemon for Nostr VPN private mesh networks

async fn paid_exit_offer_command(args: PaidExitOfferArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let app = load_or_default_config(&config_path)?;
    if args.publish {
        require_paid_exit_seller_daemon_ready(&config_path, false).await?;
    }
    let offer_id = args.offer_id.unwrap_or_else(default_paid_exit_offer_id);
    let local = build_local_paid_exit_offer(&app, &config_path, &offer_id, unix_timestamp())?;
    let provider_link = paid_exit_provider_link_for_offer(&local.offer)?;

    let publish = if args.publish {
        Some(publish_paid_exit_offer_pubsub(
            &app,
            &config_path,
            &local.signed,
        )?)
    } else {
        None
    };

    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "offer": local.offer,
                "provider_link": provider_link,
                "event": local.signed.event,
                "publish": publish,
                "store_path": local.store_path,
                "stored": local.stored,
            }))?
        );
    } else {
        println!("paid_exit_offer: {}", local.offer.offer_id);
        println!("seller: {}", local.offer.seller_npub);
        println!("provider_link: {provider_link}");
        println!(
            "price: {}",
            paid_exit_price_text(local.offer.pricing.price_msat_per_gb)
        );
        println!(
            "access: upstream={} private_vpn_access={}",
            local.offer.access.upstream.as_str(),
            local.offer.access.private_vpn_access.as_str()
        );
        println!(
            "location: country={} asn={}",
            display_or_none(&local.offer.location.country_code),
            local.offer
                .location
                .asn
                .map(|asn| asn.to_string())
                .unwrap_or_else(|| "none".to_string())
        );
        println!("event_id: {}", local.signed.event.id);
        println!(
            "store: {} changed={}",
            local.store_path.display(),
            local.stored
        );
        if let Some(publish) = publish {
            println!(
                "published: nostr-pubsub queued={}",
                publish["nostr_pubsub_queued"].as_bool().unwrap_or_default()
            );
        } else {
            println!("published: false");
        }
    }

    Ok(())
}

struct LocalPaidExitOffer {
    signed: SignedPaidRouteOffer,
    offer: PaidRouteOffer,
    store_path: PathBuf,
    stored: bool,
}

fn build_local_paid_exit_offer(
    app: &AppConfig,
    config_path: &Path,
    offer_id: &str,
    now_unix: u64,
) -> Result<LocalPaidExitOffer> {
    let config = paid_exit_offer_config(app)?;
    let receiver_pubkey_hex = paid_exit_spilman_receiver_pubkey_hex(config_path, &config)?;
    let fips_endpoints = if app.fips_advertise_public_endpoint {
        normalize_fips_peer_endpoint_hint(&app.node.endpoint)
            .into_iter()
            .collect::<Vec<_>>()
    } else {
        Vec::new()
    };
    let signed = signed_paid_exit_offer_from_config_with_receiver_and_fips_endpoints(
        offer_id,
        &app.nostr_keys()?,
        &config,
        receiver_pubkey_hex.as_deref(),
        &fips_endpoints,
        Some(PaidRouteQualityMetrics {
            last_seen_unix: Some(now_unix),
            ..PaidRouteQualityMetrics::default()
        }),
        now_unix,
    )?;
    let offer = signed.offer()?;
    let store_path = paid_route_store_file_path(config_path);
    let stored = persist_paid_exit_offer_snapshot(&store_path, &signed, &[], &offer, now_unix)?;
    Ok(LocalPaidExitOffer {
        signed,
        offer,
        store_path,
        stored,
    })
}

fn paid_exit_import_offer_command(args: PaidExitImportOfferArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let event_json = read_paid_exit_offer_event(args.event, args.event_stdin, args.event_file)?;
    let event: Event = serde_json::from_str(&event_json)
        .context("failed to decode paid route offer event JSON")?;
    let signed = SignedPaidRouteOffer::from_event(event)
        .context("failed to verify paid route offer event")?;
    let offer = signed.offer()?;
    let store_path = paid_route_store_file_path(&config_path);
    let changed =
        upsert_paid_route_offer(&store_path, signed.clone(), vec![], unix_timestamp())?;

    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "offer": offer,
                "event": signed.event,
                "store_path": store_path,
                "stored": changed,
            }))?
        );
    } else {
        println!("paid_exit_offer: {}", offer.offer_id);
        println!("seller: {}", offer.seller_npub);
        println!("event_id: {}", signed.event.id);
        println!("store: {} changed={changed}", store_path.display());
    }

    Ok(())
}

fn paid_exit_offer_event_is_live(
    event: &Event,
    retention_policy: &nostr_pubsub::EventRetentionPolicy,
    seller: Option<&PublicKey>,
    now_unix: u64,
) -> bool {
    if seller.is_some_and(|seller| &event.pubkey != seller) {
        return false;
    }
    nostr_pubsub::VerifiedEvent::try_from(event.clone())
        .is_ok_and(|verified| retention_policy.accepts(&verified))
        && SignedPaidRouteOffer::from_event(event.clone())
            .is_ok_and(|signed| signed.is_live_at(now_unix))
}

async fn wait_for_paid_exit_control_events(
    config_path: &Path,
    retention_policy: &nostr_pubsub::EventRetentionPolicy,
    seller: Option<&PublicKey>,
    duration_secs: u64,
) -> Result<Vec<Event>> {
    let deadline = Instant::now() + Duration::from_secs(duration_secs);
    let mut events = crate::control_pubsub_runtime::load_control_pubsub_events(config_path)?;
    if duration_secs == 0 {
        return Ok(events);
    }
    let initial_offer_ids = events
        .iter()
        .filter(|event| {
            paid_exit_offer_event_is_live(
                event,
                retention_policy,
                seller,
                unix_timestamp(),
            )
        })
        .map(|event| event.id)
        .collect::<HashSet<_>>();
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Ok(events);
        }
        tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
        events = crate::control_pubsub_runtime::load_control_pubsub_events(config_path)?;
        let now_unix = unix_timestamp();
        if events.iter().any(|event| {
            !initial_offer_ids.contains(&event.id)
                && paid_exit_offer_event_is_live(event, retention_policy, seller, now_unix)
        }) {
            return Ok(events);
        }
    }
}

async fn paid_exit_discover_command(args: PaidExitDiscoverArgs) -> Result<()> {
    let config_path = args.config.unwrap_or_else(default_config_path);
    let provider = args
        .provider
        .as_deref()
        .map(ManualPaidExitProvider::parse)
        .transpose()
        .context("invalid targeted paid exit provider")?;
    let seller = provider
        .as_ref()
        .map(|provider| PublicKey::parse(&provider.npub))
        .transpose()
        .context("invalid targeted paid exit seller npub")?;
    let trusted_rating_authors =
        paid_exit_trusted_rating_author_set(&args.trusted_rating_authors)?;
    let mut rating_scores = args
        .fips_peer_ratings
        .as_deref()
        .map(|path| load_paid_exit_rating_scores(path, &args.rating_scope, &trusted_rating_authors))
        .transpose()?;
    let since_unix = if args.since_secs == 0 {
        None
    } else {
        Some(unix_timestamp().saturating_sub(args.since_secs))
    };
    let retention_policy = paid_exit_offer_retention_policy(args.limit, since_unix);
    let cached_control_events = wait_for_paid_exit_control_events(
        &config_path,
        &retention_policy,
        seller.as_ref(),
        args.duration_secs,
    )
    .await?;
    let cached_rating_events = cached_control_events
        .iter()
        .filter(|event| event.kind == Kind::Custom(RATING_FACT_KIND as u16))
        .map(serde_json::to_value)
        .collect::<std::result::Result<Vec<_>, _>>()?;
    let cached_rating_event_count = cached_rating_events.len();
    if !cached_rating_events.is_empty() {
        let cached_scores = paid_exit_rating_scores_from_value(
            &json!({ "events": cached_rating_events }),
            &args.rating_scope,
            &trusted_rating_authors,
        )?;
        merge_paid_exit_rating_scores(&mut rating_scores, cached_scores);
    }
    let now_unix = unix_timestamp();
    let cached_offers = cached_control_events
        .into_iter()
        .filter_map(|event| {
            paid_exit_offer_event_is_live(
                &event,
                &retention_policy,
                seller.as_ref(),
                now_unix,
            )
                .then(|| SignedPaidRouteOffer::from_event(event).ok())
                .flatten()
        })
        .filter(|signed| {
            provider.as_ref().is_none_or(|provider| {
                signed
                    .offer()
                    .is_ok_and(|offer| provider.accepts(&offer).is_ok())
            })
        })
        .collect::<Vec<_>>();
    let cached_offer_count = cached_offers.len();
    let mut offers = cached_offers.clone();
    offers.sort_by_key(|signed| std::cmp::Reverse(signed.event.created_at.as_secs()));
    let mut seen_offer_ids = HashSet::new();
    offers.retain(|signed| seen_offer_ids.insert(signed.event.id));
    offers.truncate(retention_policy.max_events);
    if let Some(scores) = rating_scores.as_ref() {
        paid_exit_sort_offers_by_rating(&mut offers, scores);
    }
    let store_path = paid_route_store_file_path(&config_path);
    let stored_count = persist_paid_exit_discovered_offers(
        &store_path,
        &cached_offers,
        &[],
        rating_scores.as_ref(),
    )?;

    if args.json {
        let offers_json = paid_exit_offer_results_json(&offers, rating_scores.as_ref())?;
        let ratings_json = if args.fips_peer_ratings.is_some() || cached_rating_event_count > 0 {
            Some(json!({
                "path": args.fips_peer_ratings.as_ref().map(|path| path.display().to_string()),
                "scope": args.rating_scope,
                "subject_count": rating_scores.as_ref().map_or(0, HashMap::len),
                "nostr_pubsub_cached_event_count": cached_rating_event_count,
                "trusted_author_count": trusted_rating_authors.len(),
            }))
        } else {
            None
        };
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "count": offers_json.len(),
                "offers": offers_json,
                "store_path": store_path,
                "stored_count": stored_count,
                "nostr_pubsub_cached_offer_count": cached_offer_count,
                "nostr_pubsub_cached_rating_event_count": cached_rating_event_count,
                "ratings": ratings_json,
            }))?
        );
    } else {
        println!("paid_exit_offers: {}", offers.len());
        println!(
            "nostr_pubsub_cache: offers={} rating_events={}",
            cached_offer_count, cached_rating_event_count
        );
        println!("store: {} changed={stored_count}", store_path.display());
        if args.fips_peer_ratings.is_some() || cached_rating_event_count > 0 {
            let subject_count = rating_scores.as_ref().map_or(0, HashMap::len);
            let file = args
                .fips_peer_ratings
                .as_ref()
                .map(|path| path.display().to_string())
                .unwrap_or_else(|| "-".to_string());
            println!(
                "ratings: file={} scope={} subjects={} nostr_pubsub_events={} trusted_authors={}",
                file,
                args.rating_scope,
                subject_count,
                cached_rating_event_count,
                trusted_rating_authors.len()
            );
        }
        for signed in &offers {
            let offer = signed.offer()?;
            println!(
                "{}",
                paid_exit_offer_summary_line_with_rating(
                    &offer,
                    signed.event.id,
                    rating_scores.as_ref()
                )
            );
        }
    }

    Ok(())
}