feat(relay): make discoverable hardening limits explicit

Motivation: rust-nostr 0.45 introduced a broad local-relay hardening series
whose effective defaults were mostly absent from its changelog and entirely
absent from ngit-grasp's custom NIP-11 response. One new 64 KiB event bound is
incompatible with production history containing a valid roughly 149 KiB
NIP-34 patch event. Leaving other defaults implicit risks another dependency
upgrade silently changing serving policy, while exposing every internal knob
would create configuration that peers cannot usefully negotiate.

Approach: explicitly select every retained rust-nostr hardening value in the
builder. Expose only the subscription and per-filter result limits that peers
can discover and ngit-grasp sync already consumes, plus the Git-specific event
size policy. Apply one filter-limit option consistently to explicit, query,
and omitted-limit caps. Raise the event default from 64 KiB to 192 KiB and
validate it remains beneath the fixed 5 MiB WebSocket message ceiling. Publish
the standard NIP-11 limitation fields and correct the architecture/reference
documentation, including removal of the obsolete max_filters claim.

Correctness: the NIP-11 max_subscriptions value feeds the existing per-session
subscription ledger; default_limit feeds adaptive pagination with its existing
verification-page safeguard. max_limit is advertised truthfully but is not
misused as an omitted-filter promise. The 192 KiB bound clears the observed
patch by about 29% while preserving a finite allocation boundary. All three
new options are synchronized across source, reference docs, NixOS, and the
environment example.

Excluded scope: message-size negotiation, filter-payload limits, per-IP
fairness, and non-standard NIP-11 extensions remain separate work. Rate,
handshake, subscription-memory, filter-count, and negentropy bounds are pinned
but deliberately not operator-configurable because our sync cannot negotiate
them through standard NIP-11 fields.

Validation: focused unit tests passed for explicit configuration defaults,
event/WebSocket size validation, configured NIP-11 advertisement, and the full
http::nip11::tests module (10 tests before adding the focused override case).
The previously validated full library suite and deployment build were not
repeated at the user's request.
This commit is contained in:
DanConwayDev
2026-08-07 07:44:31 +00:00
parent 5654025be6
commit aa543d8051
10 changed files with 257 additions and 147 deletions
+5
View File
@@ -410,3 +410,8 @@
# CLI: --max-connections <count> # CLI: --max-connections <count>
# Default: unlimited # Default: unlimited
# NGIT_MAX_CONNECTIONS=4096 # NGIT_MAX_CONNECTIONS=4096
# Discoverable sync limits and the Git-specific event-size limit
# NGIT_RELAY_MAX_SUBSCRIPTIONS=500
# NGIT_RELAY_FILTER_LIMIT=500
# NGIT_RELAY_MAX_EVENT_SIZE_BYTES=196608
+15 -3
View File
@@ -16,9 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded the rust-nostr crates from `0.45.0-alpha.8` to the stable `0.45.0` - Upgraded the rust-nostr crates from `0.45.0-alpha.8` to the stable `0.45.0`
release, including the upstream NEG-OPEN handling fix and new local-relay release, including the upstream NEG-OPEN handling fix and new local-relay
resource hardening. ngit-grasp explicitly overrides rust-nostr's new resource hardening. The embedded relay now imposes 500 active REQs per
128-connection default to preserve its documented unbounded-by-default connection; per-minute connection quotas of 60 event writes, 120 queries,
inbound admission; explicitly configured connection caps remain exact. 30 authentication events, and 300 text messages; 20 filters per REQ; 500
results per filter; 250-byte
subscription IDs; 1 MiB retained subscription state; 10 active negentropy
sessions and 50,000 negentropy items per connection; 5 MiB WebSocket
messages; and a 10-second handshake deadline. ngit-grasp deliberately:
- makes the discoverable subscription and filter-result limits configurable
and advertises them in NIP-11;
- raises rust-nostr's new 64 KiB event bound to a configurable 192 KiB
default because valid NIP-34 patch events in production reach about
149 KiB; and
- overrides rust-nostr's new 128-connection default to preserve documented
unbounded-by-default inbound admission, while keeping configured caps exact.
### Security ### Security
+12 -1
View File
@@ -17,14 +17,25 @@ ngit-grasp employs multiple layers of defense:
### Per-Connection Rate Limits ### Per-Connection Rate Limits
**Source:** Built-in to rust-nostr relay-builder **Source:** Enforced by rust-nostr relay-builder and explicitly selected by
ngit-grasp. Discoverable sync limits and the Git-specific event bound are
operator configurable; other dependency hardening is pinned in the builder.
- **Subscription limit:** Max 500 concurrent subscriptions per connection - **Subscription limit:** Max 500 concurrent subscriptions per connection
- **Event publishing limit:** Max 60 events per minute per connection - **Event publishing limit:** Max 60 events per minute per connection
- **Subscription ID length:** Max 250 characters - **Subscription ID length:** Max 250 characters
- **Filter limit:** Max 500 results per query (default) - **Filter limit:** Max 500 results per query (default)
- **Event size:** Max 192 KiB by default, raised from rust-nostr's 64 KiB
default because valid NIP-34 patch events in production reach about 149 KiB
These limits prevent individual connections from overwhelming the relay. These limits prevent individual connections from overwhelming the relay.
The relay advertises the standard `max_subscriptions`, `max_limit`,
`default_limit`, `max_message_length`, and `max_subid_length` NIP-11 fields.
rust-nostr 0.45 also enforces fixed ngit-grasp-selected defaults of 120 queries,
30 authentication events, and 300 text messages per minute; 20 filters per
REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000
negentropy items per connection; a 5 MiB WebSocket message; and a 10-second
handshake deadline. NIP-11 has no standard fields for most of those controls.
### Per-IP Connection Monitoring ### Per-IP Connection Monitoring
+23 -20
View File
@@ -70,8 +70,8 @@ The five reachable relays advertise their result cap as
### Discoverability gap (NIP-11) ### Discoverability gap (NIP-11)
NIP-11 `limitation` has **no field for tag values per filter**. It does define NIP-11 `limitation` has **no field for tag values per filter or filter count
`max_filters` (filters per subscription), `max_limit` (clamp applied to a per subscription**. It does define `max_limit` (clamp applied to a
filter's explicit `limit`), and `default_limit` (maximum returned events when filter's explicit `limit`), and `default_limit` (maximum returned events when
`limit` is omitted — the field pagination actually needs), in addition to `limit` is omitted — the field pagination actually needs), in addition to
`max_subscriptions` and `max_message_length`, but implementations and `max_subscriptions` and `max_message_length`, but implementations and
@@ -182,7 +182,7 @@ infrastructure. The full survey with citations is preserved in this
file's history (commit `9723ff4`). file's history (commit `9723ff4`).
NIP-11 describes hard relay limitations, not rate-limit algorithms. The NIP-11 describes hard relay limitations, not rate-limit algorithms. The
standard fields relevant here are `max_limit`, `max_filters`, and standard fields relevant here are `max_limit` and
`max_subscriptions`; it has no standard fields for simultaneous connections `max_subscriptions`; it has no standard fields for simultaneous connections
per IP, connection-attempt rate, message/event/query rate, burst size, window, per IP, connection-attempt rate, message/event/query rate, burst size, window,
decay model, or retry-after time. Even an advertised `max_limit` does not say decay model, or retry-after time. Even an advertised `max_limit` does not say
@@ -250,10 +250,11 @@ Derived from the tightest commonly observed values; all sizing below assumes:
- **Subscription budget B = 20** per connection (nos.lol, relay.primal.net, - **Subscription budget B = 20** per connection (nos.lol, relay.primal.net,
Ditto Relay default), shared between live REQs, NEG rounds, and fallback Ditto Relay default), shared between live REQs, NEG rounds, and fallback
REQs. Caveat found by the 2026-08-06 limit matrix: nostream defaults to REQs. Caveat found by the 2026-08-06 limit matrix: nostream defaults to
**10** subscriptions per connection and 10 filters per REQ (both **10** subscriptions per connection and 10 filters per REQ (the former is
advertised in NIP-11), below this floor — the fixed 4 NEG + 5 REQ + 2 standard NIP-11; nostream emits the latter as a relay-specific field), below
this floor — the fixed 4 NEG + 5 REQ + 2
margin pattern alone would overdraw a default nostream before any live margin pattern alone would overdraw a default nostream before any live
subscriptions. Honouring advertised `max_subscriptions`/`max_filters` subscriptions. Honouring advertised `max_subscriptions`
is therefore required ledger work, not just an optimisation. is therefore required ledger work, not just an optimisation.
- **Message budget M = 128 KB** (nos.lol); we target ≤ 96 KB of filter payload - **Message budget M = 128 KB** (nos.lol); we target ≤ 96 KB of filter payload
per message, a 1.3× margin for the envelope. per message, a 1.3× margin for the envelope.
@@ -422,15 +423,17 @@ consequences:
## Serving-Side Obligations ## Serving-Side Obligations
We are also a relay, and peer GRASP instances run this same sync against us. We are also a relay, and peer GRASP instances run this same sync against us.
The embedded relay currently enforces **no negentropy concurrency limit** The rust-nostr 0.45 embedded relay now enforces 10 active negentropy sessions,
(upstream nostr-sdk `TODO`) and no filter-size limits — the mirror image of 20 filters per REQ, and the other bounds recorded in the relay-limits
the client-side incident that motivated this document. At scale we must: reference. ngit-grasp explicitly selects those defaults and advertises the
standard, discoverable subset. The remaining serving-side gap is a bound on
tag-value/filter payload size, for which NIP-11 has no standard field. At scale
we must:
1. Enforce server-side bounds (NEG concurrency, filters per REQ, filter 1. Retain the existing NEG, REQ, subscription-memory, message, event, and rate
payload) so one peer cannot exhaust us. bounds, and design a filter-payload bound if production evidence requires it.
2. Advertise our limits in NIP-11 `limitation` (`max_subscriptions`, 2. Keep NIP-11 `limitation` aligned with every enforced standard field so
`max_message_length`) so well-behaved peers can budget against us — well-behaved peers can budget against us.
partially compensating for the discoverability gap we suffer as a client.
--- ---
@@ -466,11 +469,11 @@ deterministic and testable.
**Pros:** honest relays advertise `max_subscriptions` and **Pros:** honest relays advertise `max_subscriptions` and
`max_message_length`; budgets could be exact. `max_message_length`; budgets could be exact.
**Why partial:** the two advertised fields *are* consumed when present **Why partial:** `max_subscriptions` and `default_limit` are consumed when
(relaxing B and M above the floors), but per-filter and filters-per-REQ present, but message-size negotiation is not yet implemented, filter count has
limits simply have no NIP-11 field, and many relays omit `limitation` no current standard NIP-11 field, and many relays omit `limitation` entirely —
entirely — so floors remain necessary. Proposing a NIP-11 extension for so floors remain necessary. Proposing a NIP-11 extension for filter-size limits
filter-size limits is worthwhile upstream work. is worthwhile upstream work.
### Timed batching with pause-on-rate-limit ### Timed batching with pause-on-rate-limit
@@ -492,7 +495,7 @@ with the heuristics demoted to backstop.
| 1 + 2 — byte-budgeted chunking and REQ packing | Landed with cycle 3 (same PR) | | 1 + 2 — byte-budgeted chunking and REQ packing | Landed with cycle 3 (same PR) |
| Ledger unification (live + historic + fallback + purgatory polling against one NIP-11-aware subscription budget) | Implemented 2026-08-06; message-budget negotiation remains follow-up | | Ledger unification (live + historic + fallback + purgatory polling against one NIP-11-aware subscription budget) | Implemented 2026-08-06; message-budget negotiation remains follow-up |
| 4 — multi-connection sharding | Deferred until a relay's target set approaches the single-connection ceiling | | 4 — multi-connection sharding | Deferred until a relay's target set approaches the single-connection ceiling |
| Serving-side limits + NIP-11 advertisement | Follow-up work item | | Serving-side limits + NIP-11 advertisement | Implemented 2026-08-07 for rust-nostr's enforceable limits and the standard discoverable subset; filter-payload bounding remains follow-up |
--- ---
+22 -6
View File
@@ -1343,13 +1343,29 @@ NGIT_MAX_CONNECTIONS=100
- When limit is reached, new connections are rejected - When limit is reached, new connections are rejected
- Existing connections continue to work normally - Existing connections continue to work normally
**Related Limits:** #### Embedded relay resource limits
Per-connection limits (built-in to relay-builder, not configurable): rust-nostr 0.45 introduced or tightened several local-relay defaults. ngit-grasp
- Max subscriptions per connection: 500 sets every effective value explicitly in code, but exposes only limits that a
- Max events per minute per connection: 60 peer can discover and use to adapt sync, plus the Git-specific event-size
- Max subscription ID length: 250 characters policy.
- Max results per filter: 500
| Environment variable | Default | Meaning |
| --- | ---: | --- |
| `NGIT_RELAY_MAX_SUBSCRIPTIONS` | `500` | Active REQ subscriptions per WebSocket connection |
| `NGIT_RELAY_FILTER_LIMIT` | `500` | Explicit and omitted-limit results per filter |
| `NGIT_RELAY_MAX_EVENT_SIZE_BYTES` | `196608` | Serialized event bytes (192 KiB) |
Every value must be greater than zero. The event limit must not exceed the
fixed 5 MiB WebSocket message bound. Its default is three times rust-nostr's
64 KiB default because production history contains a valid NIP-34 patch event
of about 149 KiB; the bound retains approximately 29% headroom.
The NIP-11 document advertises `NGIT_RELAY_MAX_SUBSCRIPTIONS` as
`max_subscriptions`, and `NGIT_RELAY_FILTER_LIMIT` as both `max_limit` and
`default_limit`. ngit-grasp peers already consume these fields for their
per-connection subscription ledger and adaptive pagination. Event size has no
equivalent standard NIP-11 field, so it remains documented configuration only.
--- ---
+41 -112
View File
@@ -1,120 +1,49 @@
# nostr-relay-builder Limits # Embedded relay limits
This document describes the rate limiting, throttling, and query limits in `nostr-relay-builder` version 0.44. These are the limits that apply to ngit-grasp and any relay built with this crate. ngit-grasp embeds rust-nostr `LocalRelay` 0.45.0. The application selects every
effective limit explicitly so future dependency defaults cannot silently alter
production admission policy.
**Note:** Other relay implementations (strfry, nostream, etc.) have different limits. This document focuses on `nostr-relay-builder` specifically. ## Effective limits
## Hard Limits (Cannot Be Changed) | Limit | ngit-grasp default | Operator configuration | NIP-11 |
| --- | ---: | --- | --- |
| Total inbound connections | Unbounded | `NGIT_MAX_CONNECTIONS` | No standard field |
| Active REQs per connection | 500 | `NGIT_RELAY_MAX_SUBSCRIPTIONS` | `max_subscriptions` |
| Results per filter | 500 | `NGIT_RELAY_FILTER_LIMIT` | `max_limit`, `default_limit` |
| Serialized event size | 192 KiB | `NGIT_RELAY_MAX_EVENT_SIZE_BYTES` | No equivalent field |
| Event writes per minute | 60 | Fixed | No standard field |
| Queries per minute | 120 | Fixed | No standard field |
| Authentication events per minute | 30 | Fixed | No standard field |
| Text messages per minute | 300 | Fixed | No standard field |
| WebSocket message size | 5 MiB | Fixed | `max_message_length` |
| Handshake deadline | 10 seconds | Fixed | No standard field |
| Subscription-ID length | 250 bytes | Fixed | `max_subid_length` |
| Filters per REQ | 20 | Fixed | No standard field |
| Subscription state per connection | 1 MiB | Fixed | No standard field |
| Active negentropy sessions per connection | 10 | Fixed | No standard field |
| Negentropy items per connection | 50,000 | Fixed | No standard field |
| Negentropy frame | 60,000 bytes | Fixed upstream | No standard field |
These limits are enforced and cannot be overridden by configuration: The filter setting is applied consistently to rust-nostr's explicit filter
cap, per-query result cap, and omitted-limit default. Limits are per filter;
results from multiple filters in one REQ are merged without an aggregate
truncation.
### WebSocket Message Limits (from tungstenite) The 192 KiB event default is three times rust-nostr's new 64 KiB default.
Production history contains a valid NIP-34 patch event of about 149 KiB, so
64 KiB is incompatible with ngit-grasp's purpose. The raised limit remains
bounded and below the 5 MiB WebSocket message ceiling.
| Limit | Default Value | Source | ## Client adaptation
|-------|--------------|--------|
| `max_message_size` | **64 MB** (67,108,864 bytes) | tungstenite default |
| `max_frame_size` | **16 MB** (16,777,216 bytes) | tungstenite default |
nostr-relay-builder does **not** override these tungstenite defaults. ngit-grasp refetches NIP-11 per connection session. Its outbound sync ledger
uses `max_subscriptions`, falling back conservatively when absent. Adaptive
historic pagination uses `default_limit` with a verification page and learns
from raw delivered page sizes. `max_limit` describes explicit filter limits;
historic sync currently omits `limit`, so it does not treat `max_limit` as an
omitted-filter page-size promise.
**Practical impact:** A single REQ message or EVENT with extremely large content could hit these limits. A filter with ~1,000,000 32-byte event IDs (~32MB in JSON) would fit, but 2 million would not. NIP-11 has no standard `max_filters` field in the current schema. Filter-count
and serialized-message budgets therefore remain conservative client-side
### Negentropy Frame Limit constants rather than falsely negotiated capabilities.
| Limit | Value | Source |
|-------|-------|--------|
| `frame_size_limit` | **60,000 bytes** (60KB) | Hardcoded in inner.rs |
```rust
let mut negentropy = Negentropy::owned(storage, 60_000)?;
```
If reconciliation needs more data, negentropy splits across multiple NEG-MSG round-trips automatically.
### No Hard Limits On
nostr-relay-builder does **NOT** enforce hard limits on:
| Item | Hard Limit? | Notes |
|------|-------------|-------|
| Tag values per filter (`#e`, `#p`, etc.) | ❌ None | Only limited by message size |
| Filters per REQ array | ❌ None | Only limited by message size |
| Filter JSON size | ❌ None | Only limited by WebSocket message |
| Authors per filter | ❌ None | Only limited by message size |
| Kinds per filter | ❌ None | Only limited by message size |
| IDs per filter | ❌ None | Only limited by message size |
## Configurable Limits (Server-Side)
These limits have defaults but can be configured via `RelayBuilder`:
### Query/Response Limits
| Setting | Default | Builder Method |
|---------|---------|----------------|
| `default_filter_limit` | **500** | `.default_filter_limit(n)` |
| `max_filter_limit` | `None` (no cap) | `.max_filter_limit(n)` |
**Behavior:**
1. If filter has no `limit` field → server applies `default_filter_limit` (500)
2. If filter has `limit > max_filter_limit` → clamped to `max_filter_limit`
3. If filter has specific `ids` → uses `ids.len()` as limit
### Rate Limiting
| Setting | Default | Description |
|---------|---------|-------------|
| `max_reqs` | **500** | Max active subscriptions per session |
| `notes_per_minute` | **60** | Token bucket rate for EVENT writes |
Rate limiting uses a token bucket: tokens regenerate proportionally over time, each EVENT consumes 1 token.
### Connection/Session Limits
| Setting | Default | Description |
|---------|---------|-------------|
| `max_connections` | `None` | Max concurrent WebSocket connections |
| `max_subid_length` | **250** | Max characters in subscription ID |
## REQ vs Negentropy Limits
| Aspect | REQ (NIP-01) | Negentropy (NIP-77) |
|--------|--------------|---------------------|
| Events returned | Limited (default: 500) | **Unlimited** (all IDs returned) |
| Filter limit applies? | ✅ Yes | ❌ No |
| Returns full events? | ✅ Yes | ❌ No (only EventId + Timestamp) |
| Message size limit | 64MB (WebSocket) | 60KB per frame |
**Why negentropy returns all:** It only returns ~40 bytes per event (ID + timestamp) for set reconciliation. Full events are fetched separately after identifying what's missing.
## Quick Reference
| Limit | Value | Type |
|-------|-------|------|
| **WebSocket message** | 64 MB | Hard (tungstenite) |
| **WebSocket frame** | 16 MB | Hard (tungstenite) |
| **Negentropy frame** | 60 KB | Hard (hardcoded) |
| Tags per filter | **None** | Soft (message size only) |
| Filters per REQ | **None** | Soft (message size only) |
| Events per REQ | 500 | Configurable default |
| Max subscriptions | 500 | Configurable default |
| Write rate | 60/min | Configurable default |
## ngit-grasp Configuration
ngit-grasp uses defaults (no custom limits configured):
```rust
let builder = RelayBuilder::default()
.database(database.clone())
.write_policy(write_policy.clone());
```
Additionally, ngit-grasp's memory database limits to **100,000 events** (LMDB has no such limit).
## Related
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md)
- [NIP-77: Negentropy Sync](https://github.com/nostr-protocol/nips/blob/master/77.md)
- [nostr-relay-builder docs](https://docs.rs/nostr-relay-builder)
- [tungstenite WebSocket limits](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html)
+19
View File
@@ -406,6 +406,22 @@ let
"Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)"; "Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)";
}; };
relayMaxSubscriptions = mkOption {
type = types.ints.positive;
default = 500;
description = "Maximum active REQ subscriptions per WebSocket connection";
};
relayMaxEventSizeBytes = mkOption {
type = types.ints.positive;
default = 192 * 1024;
description = "Maximum serialized event size in bytes";
};
relayFilterLimit = mkOption {
type = types.ints.positive;
default = 500;
description = "Per-filter result cap, including when limit is omitted";
};
user = mkOption { user = mkOption {
type = types.str; type = types.str;
default = "ngit-grasp-${name}"; default = "ngit-grasp-${name}";
@@ -491,6 +507,9 @@ let
NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false"; NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false";
NGIT_DELETION_REQUEST_DISRESPECTOR = NGIT_DELETION_REQUEST_DISRESPECTOR =
if cfg.deletionRequestDisrespector then "true" else "false"; if cfg.deletionRequestDisrespector then "true" else "false";
NGIT_RELAY_MAX_SUBSCRIPTIONS = toString cfg.relayMaxSubscriptions;
NGIT_RELAY_MAX_EVENT_SIZE_BYTES = toString cfg.relayMaxEventSizeBytes;
NGIT_RELAY_FILTER_LIMIT = toString cfg.relayFilterLimit;
} // optionalAttrs (cfg.maxConnections != null) { } // optionalAttrs (cfg.maxConnections != null) {
NGIT_MAX_CONNECTIONS = toString cfg.maxConnections; NGIT_MAX_CONNECTIONS = toString cfg.maxConnections;
} // optionalAttrs (cfg.relayName != null) { } // optionalAttrs (cfg.relayName != null) {
+60
View File
@@ -591,6 +591,22 @@ pub struct Config {
#[arg(long, env = "NGIT_MAX_CONNECTIONS")] #[arg(long, env = "NGIT_MAX_CONNECTIONS")]
pub max_connections: Option<usize>, pub max_connections: Option<usize>,
/// Maximum active REQ subscriptions per WebSocket connection
#[arg(long, env = "NGIT_RELAY_MAX_SUBSCRIPTIONS", default_value_t = 500)]
pub relay_max_subscriptions: usize,
/// Maximum serialized event size in bytes
#[arg(
long,
env = "NGIT_RELAY_MAX_EVENT_SIZE_BYTES",
default_value_t = 192 * 1024
)]
pub relay_max_event_size_bytes: usize,
/// Per-filter result cap, including when a filter omits limit
#[arg(long, env = "NGIT_RELAY_FILTER_LIMIT", default_value_t = 500)]
pub relay_filter_limit: usize,
/// Log level for application logging /// Log level for application logging
#[arg(long, env = "NGIT_LOG_LEVEL", default_value = "info")] #[arg(long, env = "NGIT_LOG_LEVEL", default_value = "info")]
pub log_level: String, pub log_level: String,
@@ -883,6 +899,24 @@ impl Config {
"used", "used",
)?; )?;
let relay_limits = [
("NGIT_RELAY_MAX_SUBSCRIPTIONS", self.relay_max_subscriptions),
(
"NGIT_RELAY_MAX_EVENT_SIZE_BYTES",
self.relay_max_event_size_bytes,
),
("NGIT_RELAY_FILTER_LIMIT", self.relay_filter_limit),
];
if let Some((name, _)) = relay_limits.iter().find(|(_, value)| *value == 0) {
return Err(anyhow!("{name} must be greater than 0"));
}
if self.relay_max_event_size_bytes > 5 * 1024 * 1024 {
return Err(anyhow!(
"NGIT_RELAY_MAX_EVENT_SIZE_BYTES must not exceed the 5 MiB WebSocket message limit"
));
}
// Fatal error: repository_whitelist with archive_read_only=true (incompatible) // Fatal error: repository_whitelist with archive_read_only=true (incompatible)
if !repository_whitelist.is_empty() { if !repository_whitelist.is_empty() {
let read_only = self.archive_read_only.unwrap_or(archive_enabled); let read_only = self.archive_read_only.unwrap_or(archive_enabled);
@@ -1076,6 +1110,9 @@ impl Config {
event_blacklist: String::new(), event_blacklist: String::new(),
deletion_request_disrespector: false, deletion_request_disrespector: false,
max_connections: None, max_connections: None,
relay_max_subscriptions: 500,
relay_max_event_size_bytes: 192 * 1024,
relay_filter_limit: 500,
log_level: "debug".to_string(), log_level: "debug".to_string(),
} }
} }
@@ -1136,6 +1173,29 @@ mod tests {
assert_eq!(config.database_backend, DatabaseBackend::Memory); assert_eq!(config.database_backend, DatabaseBackend::Memory);
} }
#[test]
fn relay_hardening_defaults_are_explicit() {
let config = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"])
.expect("relay hardening defaults should parse");
assert_eq!(config.relay_max_subscriptions, 500);
assert_eq!(config.relay_max_event_size_bytes, 192 * 1024);
assert_eq!(config.relay_filter_limit, 500);
}
#[test]
fn event_limit_cannot_exceed_websocket_limit() {
let mut config = Config::for_testing();
config.relay_max_event_size_bytes = 5 * 1024 * 1024 + 1;
let error = config
.validate()
.expect_err("inconsistent size limits must fail");
assert!(error
.to_string()
.contains("NGIT_RELAY_MAX_EVENT_SIZE_BYTES must not exceed"));
}
#[test] #[test]
fn test_deletion_request_retention_cli_defaults() { fn test_deletion_request_retention_cli_defaults() {
let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned"); let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned");
+42
View File
@@ -38,6 +38,9 @@ pub struct RelayInformationDocument {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>, pub icon: Option<String>,
/// Standard NIP-11 limits enforced by the embedded relay.
pub limitation: RelayLimitation,
// GRASP-01 Extensions (lines 24-28 of GRASP-01 spec) // GRASP-01 Extensions (lines 24-28 of GRASP-01 spec)
/// List of supported GRASPs (e.g., ["GRASP-01"]) /// List of supported GRASPs (e.g., ["GRASP-01"])
/// Required by GRASP-01 specification line 26 /// Required by GRASP-01 specification line 26
@@ -53,6 +56,16 @@ pub struct RelayInformationDocument {
pub curation: Option<String>, pub curation: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayLimitation {
pub max_message_length: usize,
pub max_subscriptions: usize,
pub max_limit: usize,
pub max_subid_length: usize,
pub default_limit: usize,
pub restricted_writes: bool,
}
impl RelayInformationDocument { impl RelayInformationDocument {
/// Create NIP-11 relay information document from configuration /// Create NIP-11 relay information document from configuration
pub fn from_config(config: &Config) -> Self { pub fn from_config(config: &Config) -> Self {
@@ -131,6 +144,14 @@ impl RelayInformationDocument {
None => env!("CARGO_PKG_VERSION").to_string(), None => env!("CARGO_PKG_VERSION").to_string(),
}, },
icon: Some(format!("https://{}/icon.png", config.domain)), icon: Some(format!("https://{}/icon.png", config.domain)),
limitation: RelayLimitation {
max_message_length: 5 * 1024 * 1024,
max_subscriptions: config.relay_max_subscriptions,
max_limit: config.relay_filter_limit,
max_subid_length: 250,
default_limit: config.relay_filter_limit,
restricted_writes: true,
},
// GRASP Extensions // GRASP Extensions
supported_grasps, supported_grasps,
@@ -184,6 +205,11 @@ mod tests {
doc.icon, doc.icon,
Some("https://relay.example.com/icon.png".to_string()) Some("https://relay.example.com/icon.png".to_string())
); );
assert_eq!(doc.limitation.max_subscriptions, 500);
assert_eq!(doc.limitation.max_limit, 500);
assert_eq!(doc.limitation.default_limit, 500);
assert_eq!(doc.limitation.max_message_length, 5 * 1024 * 1024);
assert!(doc.limitation.restricted_writes);
} }
#[test] #[test]
@@ -211,6 +237,22 @@ mod tests {
assert_eq!(parsed["supported_grasps"][0], "GRASP-01"); assert_eq!(parsed["supported_grasps"][0], "GRASP-01");
assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); assert_eq!(parsed["icon"], "https://relay.example.com/icon.png");
assert_eq!(parsed["limitation"]["max_subscriptions"], 500);
assert_eq!(parsed["limitation"]["max_limit"], 500);
assert_eq!(parsed["limitation"]["default_limit"], 500);
}
#[test]
fn test_nip11_advertises_configured_sync_limits() {
let mut config = Config::for_testing();
config.relay_max_subscriptions = 20;
config.relay_filter_limit = 300;
let doc = RelayInformationDocument::from_config(&config);
assert_eq!(doc.limitation.max_subscriptions, 20);
assert_eq!(doc.limitation.max_limit, 300);
assert_eq!(doc.limitation.default_limit, 300);
} }
#[test] #[test]
+18 -5
View File
@@ -10,6 +10,7 @@ use std::num::NonZeroUsize;
use std::path::Path; use std::path::Path;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use nostr::nips::nip19::ToBech32; use nostr::nips::nip19::ToBech32;
@@ -1007,12 +1008,24 @@ pub async fn create_relay(
let mut builder = LocalRelayBuilder::default() let mut builder = LocalRelayBuilder::default()
.database(database.clone()) .database(database.clone())
.write_policy(write_policy.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 { .rate_limit(RateLimit {
max_reqs: 500, // Max concurrent subscriptions per connection max_reqs: config.relay_max_subscriptions,
notes_per_minute: 60, // Max events per minute per connection notes_per_minute: 60,
}); })
.queries_per_minute(120)
.auth_events_per_minute(30)
.messages_per_minute(300)
.max_websocket_message_size(5 * 1024 * 1024)
.max_event_size(config.relay_max_event_size_bytes)
.websocket_handshake_timeout(Duration::from_secs(10))
.max_subid_length(250)
.max_filters_per_req(20)
.max_subscription_bytes(1024 * 1024)
.max_negentropy_subscriptions(10)
.max_negentropy_items(50_000)
.max_filter_limit(config.relay_filter_limit)
.max_query_results(config.relay_filter_limit)
.default_filter_limit(config.relay_filter_limit);
// `LocalRelayBuilder` otherwise applies rust-nostr's own finite default. // `LocalRelayBuilder` otherwise applies rust-nostr's own finite default.
// In ngit-grasp, an unset limit deliberately delegates admission control to // In ngit-grasp, an unset limit deliberately delegates admission control to