mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
@@ -410,3 +410,8 @@
|
||||
# CLI: --max-connections <count>
|
||||
# Default: unlimited
|
||||
# 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
@@ -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`
|
||||
release, including the upstream NEG-OPEN handling fix and new local-relay
|
||||
resource hardening. ngit-grasp explicitly overrides rust-nostr's new
|
||||
128-connection default to preserve its documented unbounded-by-default
|
||||
inbound admission; explicitly configured connection caps remain exact.
|
||||
resource hardening. The embedded relay now imposes 500 active REQs per
|
||||
connection; per-minute connection quotas of 60 event writes, 120 queries,
|
||||
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
|
||||
|
||||
|
||||
@@ -17,14 +17,25 @@ ngit-grasp employs multiple layers of defense:
|
||||
|
||||
### 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
|
||||
- **Event publishing limit:** Max 60 events per minute per connection
|
||||
- **Subscription ID length:** Max 250 characters
|
||||
- **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.
|
||||
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
|
||||
|
||||
|
||||
@@ -70,8 +70,8 @@ The five reachable relays advertise their result cap as
|
||||
|
||||
### Discoverability gap (NIP-11)
|
||||
|
||||
NIP-11 `limitation` has **no field for tag values per filter**. It does define
|
||||
`max_filters` (filters per subscription), `max_limit` (clamp applied to a
|
||||
NIP-11 `limitation` has **no field for tag values per filter or filter count
|
||||
per subscription**. It does define `max_limit` (clamp applied to a
|
||||
filter's explicit `limit`), and `default_limit` (maximum returned events when
|
||||
`limit` is omitted — the field pagination actually needs), in addition to
|
||||
`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`).
|
||||
|
||||
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
|
||||
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
|
||||
@@ -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,
|
||||
Ditto Relay default), shared between live REQs, NEG rounds, and fallback
|
||||
REQs. Caveat found by the 2026-08-06 limit matrix: nostream defaults to
|
||||
**10** subscriptions per connection and 10 filters per REQ (both
|
||||
advertised in NIP-11), below this floor — the fixed 4 NEG + 5 REQ + 2
|
||||
**10** subscriptions per connection and 10 filters per REQ (the former is
|
||||
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
|
||||
subscriptions. Honouring advertised `max_subscriptions`/`max_filters`
|
||||
subscriptions. Honouring advertised `max_subscriptions`
|
||||
is therefore required ledger work, not just an optimisation.
|
||||
- **Message budget M = 128 KB** (nos.lol); we target ≤ 96 KB of filter payload
|
||||
per message, a 1.3× margin for the envelope.
|
||||
@@ -422,15 +423,17 @@ consequences:
|
||||
## Serving-Side Obligations
|
||||
|
||||
We are also a relay, and peer GRASP instances run this same sync against us.
|
||||
The embedded relay currently enforces **no negentropy concurrency limit**
|
||||
(upstream nostr-sdk `TODO`) and no filter-size limits — the mirror image of
|
||||
the client-side incident that motivated this document. At scale we must:
|
||||
The rust-nostr 0.45 embedded relay now enforces 10 active negentropy sessions,
|
||||
20 filters per REQ, and the other bounds recorded in the relay-limits
|
||||
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
|
||||
payload) so one peer cannot exhaust us.
|
||||
2. Advertise our limits in NIP-11 `limitation` (`max_subscriptions`,
|
||||
`max_message_length`) so well-behaved peers can budget against us —
|
||||
partially compensating for the discoverability gap we suffer as a client.
|
||||
1. Retain the existing NEG, REQ, subscription-memory, message, event, and rate
|
||||
bounds, and design a filter-payload bound if production evidence requires it.
|
||||
2. Keep NIP-11 `limitation` aligned with every enforced standard field so
|
||||
well-behaved peers can budget against us.
|
||||
|
||||
---
|
||||
|
||||
@@ -466,11 +469,11 @@ deterministic and testable.
|
||||
|
||||
**Pros:** honest relays advertise `max_subscriptions` and
|
||||
`max_message_length`; budgets could be exact.
|
||||
**Why partial:** the two advertised fields *are* consumed when present
|
||||
(relaxing B and M above the floors), but per-filter and filters-per-REQ
|
||||
limits simply have no NIP-11 field, and many relays omit `limitation`
|
||||
entirely — so floors remain necessary. Proposing a NIP-11 extension for
|
||||
filter-size limits is worthwhile upstream work.
|
||||
**Why partial:** `max_subscriptions` and `default_limit` are consumed when
|
||||
present, but message-size negotiation is not yet implemented, filter count has
|
||||
no current standard NIP-11 field, and many relays omit `limitation` entirely —
|
||||
so floors remain necessary. Proposing a NIP-11 extension for filter-size limits
|
||||
is worthwhile upstream work.
|
||||
|
||||
### 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1343,13 +1343,29 @@ NGIT_MAX_CONNECTIONS=100
|
||||
- When limit is reached, new connections are rejected
|
||||
- Existing connections continue to work normally
|
||||
|
||||
**Related Limits:**
|
||||
#### Embedded relay resource limits
|
||||
|
||||
Per-connection limits (built-in to relay-builder, not configurable):
|
||||
- Max subscriptions per connection: 500
|
||||
- Max events per minute per connection: 60
|
||||
- Max subscription ID length: 250 characters
|
||||
- Max results per filter: 500
|
||||
rust-nostr 0.45 introduced or tightened several local-relay defaults. ngit-grasp
|
||||
sets every effective value explicitly in code, but exposes only limits that a
|
||||
peer can discover and use to adapt sync, plus the Git-specific event-size
|
||||
policy.
|
||||
|
||||
| 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
@@ -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 |
|
||||
|-------|--------------|--------|
|
||||
| `max_message_size` | **64 MB** (67,108,864 bytes) | tungstenite default |
|
||||
| `max_frame_size` | **16 MB** (16,777,216 bytes) | tungstenite default |
|
||||
## Client adaptation
|
||||
|
||||
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.
|
||||
|
||||
### Negentropy Frame Limit
|
||||
|
||||
| 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)
|
||||
NIP-11 has no standard `max_filters` field in the current schema. Filter-count
|
||||
and serialized-message budgets therefore remain conservative client-side
|
||||
constants rather than falsely negotiated capabilities.
|
||||
|
||||
@@ -406,6 +406,22 @@ let
|
||||
"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 {
|
||||
type = types.str;
|
||||
default = "ngit-grasp-${name}";
|
||||
@@ -491,6 +507,9 @@ let
|
||||
NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false";
|
||||
NGIT_DELETION_REQUEST_DISRESPECTOR =
|
||||
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) {
|
||||
NGIT_MAX_CONNECTIONS = toString cfg.maxConnections;
|
||||
} // optionalAttrs (cfg.relayName != null) {
|
||||
|
||||
@@ -591,6 +591,22 @@ pub struct Config {
|
||||
#[arg(long, env = "NGIT_MAX_CONNECTIONS")]
|
||||
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
|
||||
#[arg(long, env = "NGIT_LOG_LEVEL", default_value = "info")]
|
||||
pub log_level: String,
|
||||
@@ -883,6 +899,24 @@ impl Config {
|
||||
"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)
|
||||
if !repository_whitelist.is_empty() {
|
||||
let read_only = self.archive_read_only.unwrap_or(archive_enabled);
|
||||
@@ -1076,6 +1110,9 @@ impl Config {
|
||||
event_blacklist: String::new(),
|
||||
deletion_request_disrespector: false,
|
||||
max_connections: None,
|
||||
relay_max_subscriptions: 500,
|
||||
relay_max_event_size_bytes: 192 * 1024,
|
||||
relay_filter_limit: 500,
|
||||
log_level: "debug".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1136,6 +1173,29 @@ mod tests {
|
||||
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]
|
||||
fn test_deletion_request_retention_cli_defaults() {
|
||||
let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned");
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct RelayInformationDocument {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)
|
||||
/// List of supported GRASPs (e.g., ["GRASP-01"])
|
||||
/// Required by GRASP-01 specification line 26
|
||||
@@ -53,6 +56,16 @@ pub struct RelayInformationDocument {
|
||||
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 {
|
||||
/// Create NIP-11 relay information document from configuration
|
||||
pub fn from_config(config: &Config) -> Self {
|
||||
@@ -131,6 +144,14 @@ impl RelayInformationDocument {
|
||||
None => env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
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
|
||||
supported_grasps,
|
||||
@@ -184,6 +205,11 @@ mod tests {
|
||||
doc.icon,
|
||||
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]
|
||||
@@ -211,6 +237,22 @@ mod tests {
|
||||
assert_eq!(parsed["supported_grasps"][0], "GRASP-01");
|
||||
assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
|
||||
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]
|
||||
|
||||
+18
-5
@@ -10,6 +10,7 @@ use std::num::NonZeroUsize;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
@@ -1007,12 +1008,24 @@ pub async fn create_relay(
|
||||
let mut builder = 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
|
||||
});
|
||||
max_reqs: config.relay_max_subscriptions,
|
||||
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.
|
||||
// In ngit-grasp, an unset limit deliberately delegates admission control to
|
||||
|
||||
Reference in New Issue
Block a user