Files
ngit-grasp/src/nostr/builder.rs
DanConwayDev 50000cd9d4 Add explicit rate limits and total connection limit
- Make RateLimit explicit in relay builder (500 subs, 60 events/min)
- Add NGIT_MAX_CONNECTIONS config option (default: 500)
- Update all 4 config locations (src, nix, docs, .env.example)
- Fix documentation error: filter limit 5000→500
- Document Phase 2 deferral decision (per-IP enforcement)

Addresses primary DoS vector (connection exhaustion) with minimal code.
Per-IP rate limiting deferred until abuse detected in production.

Related: issue ff38 (git endpoint throttling - separate concern)
2026-01-14 13:40:03 +00:00

648 lines
26 KiB
Rust

/// Nostr Relay Builder Configuration
///
/// This module integrates nostr-relay-builder with NIP-34 validation logic
/// using modular sub-policies for each event type.
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::path::Path;
use std::sync::Arc;
use nostr::nips::nip19::ToBech32;
use nostr_lmdb::NostrLmdb;
use nostr_relay_builder::prelude::*;
use crate::config::{Config, DatabaseBackend};
use crate::nostr::events::RepositoryAnnouncement;
use crate::nostr::policy::{
AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult,
RelatedEventPolicy, StatePolicy, StateResult,
};
/// Type alias for the shared database used by the relay
pub type SharedDatabase = Arc<dyn NostrDatabase>;
/// NIP-34 Write Policy with Full GRASP-01 Event Validation
///
/// Validates all events according to GRASP-01 specification using modular sub-policies:
/// - `AnnouncementPolicy` - Repository announcement validation
/// - `StatePolicy` - State event validation + ref alignment
/// - `PrEventPolicy` - PR/PR Update validation
/// - `RelatedEventPolicy` - Forward/backward reference checking
///
/// Uses stateful database queries to check event relationships.
#[derive(Clone)]
pub struct Nip34WritePolicy {
ctx: PolicyContext,
announcement_policy: AnnouncementPolicy,
state_policy: StatePolicy,
pr_event_policy: PrEventPolicy,
related_event_policy: RelatedEventPolicy,
}
impl std::fmt::Debug for Nip34WritePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Nip34WritePolicy")
.field("domain", &self.ctx.domain)
.field("git_data_path", &self.ctx.git_data_path)
.field("database", &"<database>")
.finish()
}
}
impl Nip34WritePolicy {
pub fn new(
database: SharedDatabase,
git_data_path: impl Into<std::path::PathBuf>,
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
config: crate::config::Config,
) -> Self {
let ctx = PolicyContext::new(
&config.domain,
database,
git_data_path,
purgatory,
config.clone(),
);
Self {
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
state_policy: StatePolicy::new(ctx.clone()),
pr_event_policy: PrEventPolicy::new(ctx.clone()),
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
ctx,
}
}
/// Check if an event author is blacklisted
///
/// Returns Some(reason) if blacklisted, None if not blacklisted.
fn check_event_blacklist(&self, event: &Event) -> Option<String> {
let event_blacklist = self.ctx.config.event_blacklist_config();
if !event_blacklist.enabled() {
return None;
}
let npub = event.pubkey.to_bech32().ok()?;
event_blacklist.check(&npub)
}
/// Get a reference to the purgatory for read-only access
pub fn purgatory(&self) -> &std::sync::Arc<crate::purgatory::Purgatory> {
&self.ctx.purgatory
}
/// Set the local relay for purgatory notifications.
///
/// This must be called after the relay is created since the relay depends
/// on this policy, but purgatory sync needs the relay to notify subscribers.
pub fn set_local_relay(&self, relay: nostr_relay_builder::LocalRelay) {
self.ctx.set_local_relay(relay);
}
/// Handle repository announcement event
async fn handle_announcement(&self, event: &Event) -> WritePolicyResult {
let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
match self.announcement_policy.validate(event).await {
AnnouncementResult::Accept => {
// Parse announcement to get repository details
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
// Try to create bare repository if it doesn't exist
if let Err(e) = self
.announcement_policy
.ensure_bare_repository(&announcement)
{
tracing::warn!(
"Failed to create bare repository for {}: {}",
event_id_str,
e
);
// Note: We still accept the event even if repo creation fails
}
tracing::debug!("Accepted repository announcement: {}", event_id_str);
// Check purgatory for state events that might now be authorized
self.check_purgatory_state_events_for_identifier(&announcement.identifier)
.await;
WritePolicyResult::Accept
}
Err(e) => {
tracing::warn!(
"Failed to parse repository announcement {}: {}",
event_id_str,
e
);
WritePolicyResult::reject(format!("Failed to parse announcement: {}", e))
}
}
}
AnnouncementResult::AcceptMaintainer => {
// Parse announcement to get details for logging
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
tracing::info!(
"Accepted maintainer announcement {} (author {} is listed as maintainer for {})",
event_id_str,
event.pubkey.to_hex(),
announcement.identifier
);
// Don't create bare repository for external announcements
// Check purgatory for state events that might now be authorized
self.check_purgatory_state_events_for_identifier(&announcement.identifier)
.await;
WritePolicyResult::Accept
}
Err(e) => {
tracing::warn!(
"Failed to parse maintainer announcement {}: {}",
event_id_str,
e
);
WritePolicyResult::reject(format!("Failed to parse announcement: {}", e))
}
}
}
AnnouncementResult::AcceptArchive => {
// GRASP-05: Archive mode - accept announcement but don't create bare repository
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
tracing::info!(
"Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)",
event_id_str,
announcement.owner_npub(),
announcement.identifier
);
// Don't create bare repository for archived announcements
// Check purgatory for state events that might now be authorized
self.check_purgatory_state_events_for_identifier(&announcement.identifier)
.await;
WritePolicyResult::Accept
}
Err(e) => {
tracing::warn!(
"Failed to parse archive announcement {}: {}",
event_id_str,
e
);
WritePolicyResult::reject(format!("Failed to parse announcement: {}", e))
}
}
}
AnnouncementResult::Reject(reason) => {
tracing::warn!(
"Rejected repository announcement {}: {}",
event_id_str,
reason
);
WritePolicyResult::reject(reason)
}
}
}
/// Handle repository state event
///
/// # Arguments
/// * `event` - The state event to validate
/// * `is_synced` - True if this event came from proactive sync (vs user-submitted)
async fn handle_state(&self, event: &Event, is_synced: bool) -> WritePolicyResult {
let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
match self.state_policy.validate(event) {
StateResult::Accept => {
// Process state alignment asynchronously
match self
.state_policy
.process_state_event(event, is_synced)
.await
{
Ok(poilicy_result) => poilicy_result,
Err(e) => {
tracing::warn!("Failed to process state event {}: {}", event_id_str, e);
// reject if processing failed
WritePolicyResult::Reject {
status: false,
message: format!("error: {e}").into(),
}
}
}
}
StateResult::Reject(reason) => {
tracing::warn!("Rejected repository state {}: {}", event_id_str, reason);
WritePolicyResult::reject(reason)
}
}
}
/// Handle PR or PR Update event
///
/// # Arguments
/// * `event` - The PR event to validate
/// * `is_synced` - True if this event came from proactive sync (vs user-submitted)
async fn handle_pr_event(&self, event: &Event, is_synced: bool) -> WritePolicyResult {
let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
// duplicate check in purgatory
let in_purgatory = self
.ctx
.purgatory
.find_pr(&event.id.to_hex())
.is_some_and(|e| e.event.is_some());
if in_purgatory {
tracing::debug!(
"processed PR event duplicate (already in purgatory): {}",
event.id,
);
return WritePolicyResult::Reject {
status: true, // Client sees OK
message: "duplicate: in purgatory".into(),
};
}
// duplicate check in db
match &self.ctx.database.check_id(&event.id).await {
Ok(DatabaseEventStatus::Saved) => {
return WritePolicyResult::Reject {
status: true, // Client sees OK
message: "duplicate".into(),
};
}
Ok(DatabaseEventStatus::Deleted) => {
return WritePolicyResult::Reject {
status: false,
message: "invalid: accepted deletion request for this event".into(),
};
}
Err(e) => {
return WritePolicyResult::Reject {
status: false,
message: format!("error: internal error: {e}").into(),
};
}
_ => {} // continue
}
// Reject PRs unrelated to stored repositories / events
match self.handle_related_event(event, "PR").await {
WritePolicyResult::Accept => {} // continue
rejected => return rejected,
}
// Check if git data exists (delete any incorrect commits at refs/nostr/<event-id>, copies correct data to relivant repositories)
match self.pr_event_policy.git_data_check(event).await {
Ok(false) => {
// Only reject expired events if they're from sync (not user-submitted)
// User-submitted events should be allowed to retry in case git data became available
if is_synced && self.ctx.purgatory.is_expired(&event.id) {
tracing::debug!(
event_id = %event_id_str,
"PR event previously expired from purgatory (synced), rejecting to prevent re-sync loop"
);
return WritePolicyResult::Reject {
status: false,
message: "invalid: previously expired from purgatory without git data"
.into(),
};
}
// No git data exists - add to purgatory
let commit = event
.tags
.iter()
.find_map(|tag| {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2 && tag_vec[0] == "c" {
Some(tag_vec[1].clone())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
tracing::info!(
"PR event {} added to purgatory: waiting for git push with commit {}",
event_id_str,
commit
);
// Add to purgatory
self.ctx
.purgatory
.add_pr(event.clone(), event.id.to_hex(), commit.clone());
WritePolicyResult::Reject {
status: true, // Client sees OK
message: format!(
"purgatory: PR event stored, waiting for git push with commit {}",
commit
)
.into(),
}
}
Ok(true) => {
// Git data exists - proceed with normal validation
tracing::debug!("Git data exists for PR event {}", event_id_str);
WritePolicyResult::Accept
}
Err(e) => {
// Error checking git data - reject event
tracing::warn!(
"Failed to check git data for PR event {}: {}",
event_id_str,
e
);
WritePolicyResult::reject(format!("Failed to check git data: {}", e))
}
}
}
/// Check purgatory for state events that might now be authorized by a new announcement
///
/// When an announcement is accepted, state events in purgatory that were previously
/// rejected due to missing announcements might now be authorized. This method:
/// 1. Finds all state events in purgatory for the identifier
/// 2. Re-evaluates authorization for each event
/// 3. Processes authorized events (releases from purgatory)
/// 4. Keeps unauthorized events in purgatory (will expire naturally)
async fn check_purgatory_state_events_for_identifier(&self, identifier: &str) {
let state_events = self.ctx.purgatory.find_state(identifier);
if state_events.is_empty() {
return;
}
tracing::debug!(
identifier = %identifier,
count = state_events.len(),
"Checking purgatory state events after announcement acceptance"
);
for entry in state_events {
// Re-evaluate authorization with the new announcement
match self
.state_policy
.process_state_event(&entry.event, false)
.await
{
Ok(WritePolicyResult::Accept) => {
tracing::info!(
event_id = %entry.event.id,
identifier = %identifier,
"State event in purgatory now authorized, will be processed"
);
// Event will be automatically removed from purgatory by process_state_event
// and broadcast to subscribers
}
Ok(WritePolicyResult::Reject { message, .. }) => {
if message.contains("not authorized") {
tracing::debug!(
event_id = %entry.event.id,
identifier = %identifier,
"State event in purgatory still not authorized, keeping in purgatory"
);
// Keep in purgatory - will expire naturally after 30 minutes
} else {
tracing::debug!(
event_id = %entry.event.id,
identifier = %identifier,
reason = %message,
"State event in purgatory rejected for other reason"
);
}
}
Err(e) => {
tracing::warn!(
event_id = %entry.event.id,
identifier = %identifier,
error = %e,
"Error re-evaluating state event in purgatory"
);
}
}
}
}
/// Handle events that must reference accepted repositories or events
async fn handle_related_event(&self, event: &Event, event_type: &str) -> WritePolicyResult {
let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
match self.related_event_policy.check_references(event).await {
Ok(ReferenceResult::ReferencesRepository(addr_ref)) => {
tracing::debug!(
"Accepted {} event {}: references accepted repository {}",
event_type,
event_id_str,
addr_ref
);
WritePolicyResult::Accept
}
Ok(ReferenceResult::ReferencesEvent(event_ref)) => {
tracing::debug!(
"Accepted {} event {}: references accepted event {}",
event_type,
event_id_str,
event_ref
);
WritePolicyResult::Accept
}
Ok(ReferenceResult::ReferencedByAccepted) => {
tracing::debug!(
"Accepted {} event {}: referenced by accepted event",
event_type,
event_id_str
);
WritePolicyResult::Accept
}
Ok(ReferenceResult::Orphan) => {
let (addressable_refs, event_refs) =
RelatedEventPolicy::extract_reference_tags(event);
tracing::info!(
"Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
event_type,
event_id_str,
addressable_refs.len(),
event_refs.len()
);
WritePolicyResult::reject(format!(
"{} event must reference an accepted repository or accepted event",
event_type
))
}
Err(e) => {
tracing::warn!(
"Database query failed for {} {}, rejecting (fail-secure): {}",
event_type,
event_id_str,
e
);
WritePolicyResult::reject(format!("Database query failed: {}", e))
}
}
}
}
impl WritePolicy for Nip34WritePolicy {
fn admit_event<'a>(
&'a self,
event: &'a nostr_relay_builder::prelude::Event,
addr: &'a SocketAddr,
) -> BoxedFuture<'a, WritePolicyResult> {
Box::pin(async move {
// Check event blacklist FIRST - it overrides everything
if let Some(reason) = self.check_event_blacklist(event) {
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
author = %event.pubkey.to_hex(),
reason = %reason,
"Rejected event from blacklisted author"
);
return WritePolicyResult::reject(reason);
}
// Detect if this is a synced event (from proactive sync) vs user-submitted
// Sync uses localhost:0 as a dummy address
let is_synced = addr.ip().is_loopback() && addr.port() == 0;
match event.kind {
Kind::GitRepoAnnouncement => self.handle_announcement(event).await,
Kind::RepoState => self.handle_state(event, is_synced).await,
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
self.handle_pr_event(event, is_synced).await
}
Kind::GitUserGraspList => {
// Accept all kind 10317 (User Grasp List) events
// for better GRASP repository discovery
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
author = %event.pubkey.to_hex(),
"Accepted kind 10317 user grasp list"
);
WritePolicyResult::Accept
}
_ => self.handle_related_event(event, "Event").await,
}
})
}
}
/// Result of creating a relay - includes relay, database, and write policy
pub struct RelayWithDatabase {
/// The local relay instance
pub relay: LocalRelay,
/// The database Arc that can be used for direct queries
pub database: SharedDatabase,
/// The write policy used for event validation
pub write_policy: Nip34WritePolicy,
}
/// Create a configured LocalRelay with full GRASP-01 validation
///
/// Returns a `RelayWithDatabase` struct containing:
/// - The `LocalRelay` for handling WebSocket connections
/// - The `SharedDatabase` for direct database queries (e.g., push authorization)
pub async fn create_relay(
config: &Config,
purgatory: Arc<crate::purgatory::Purgatory>,
) -> Result<RelayWithDatabase> {
tracing::info!("Configuring nostr relay with GRASP-01 validation...");
// Determine database path
let db_path = Path::new(&config.relay_data_path);
// Create database based on configuration
let database: SharedDatabase = match config.database_backend {
DatabaseBackend::Memory => {
tracing::info!("Using in-memory database (no persistence)");
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
events: true,
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
}))
}
DatabaseBackend::NostrDb => {
tracing::info!("Using NostrDB backend at: {}", db_path.display());
// TODO: Implement NostrDB backend once nostr-relay-builder supports it
// For now, fall back to memory database
tracing::warn!("NostrDB backend not yet implemented, using in-memory database");
Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
events: true,
max_events: Some(NonZeroUsize::new(100_000).unwrap()),
}))
}
DatabaseBackend::Lmdb => {
tracing::info!("Using LMDB backend at: {}", db_path.display());
// Ensure the database directory exists
std::fs::create_dir_all(db_path).map_err(|e| {
anyhow::anyhow!(
"Failed to create LMDB directory {}: {}",
db_path.display(),
e
)
})?;
Arc::new(NostrLmdb::open(db_path).await.map_err(|e| {
anyhow::anyhow!(
"Failed to open LMDB database at {}: {}",
db_path.display(),
e
)
})?)
}
};
// Build relay with GRASP-01 validation
// Clone Arc for the write policy so both relay and policy can access the database
let git_data_path = config.effective_git_data_path();
// Log archive configuration (config.validate() must be called at startup)
let archive_config = config.archive_config();
if archive_config.enabled() {
tracing::info!(
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
archive_config.archive_all,
archive_config.whitelist.len(),
archive_config.read_only
);
}
// Log repository configuration
let repository_config = config.repository_config();
if repository_config.enabled() {
tracing::info!(
"Repository whitelist enabled: whitelist_entries={}",
repository_config.whitelist.len()
);
}
// Create write policy with purgatory integration
let write_policy =
Nip34WritePolicy::new(database.clone(), &git_data_path, purgatory, config.clone());
let relay = LocalRelayBuilder::default()
.database(database.clone())
.write_policy(write_policy.clone())
// Explicitly set rate limits (make defaults visible in code)
// Per-connection limits: 500 max subscriptions, 60 events/min
.rate_limit(RateLimit {
max_reqs: 500, // Max concurrent subscriptions per connection
notes_per_minute: 60, // Max events per minute per connection
})
// Total connection limit to prevent DoS attacks
.max_connections(config.max_connections)
.build();
tracing::info!(
"Relay configured with GRASP-01 validation for domain: {}",
config.domain
);
Ok(RelayWithDatabase {
relay,
database,
write_policy,
})
}