28 KiB
UDP Nostr Ingress Integration Plan
Overview
Integrate the udp_nostr concept into c-relay-pg: a direct UDP
listener that receives signed Nostr events as single datagrams (≤1472 bytes, JSON
format), validates them through the existing authoritative pipeline, stores them, and
broadcasts to WebSocket subscribers — all with no handshake, no response, and no
connection state.
The design is structured so a future authoritative DNS relay ingress (events
received via DNS queries on port 53) can reuse the same completion queue, config keys,
and ingest_event() source path with minimal additional work.
Background & Key Constraints
From the udp_nostr project:
- No handshake: A single UDP datagram carries a self-validating Nostr event. The signature replaces the handshake. The relay never responds to invalid events (silent drop — no protocol fingerprint for active probing).
- Single-packet guarantee: 1500 Ethernet MTU − 20 IP − 8 UDP = 1472 bytes max payload. Events larger than this are silently dropped (no fragmentation).
- Stateless: No connection state, no session, no OK response. The relay receives, validates, stores, and forgets the sender.
- Fire-and-forget: UDP provides no delivery guarantee. This is acceptable for the ingress path — the sender does not wait for an acknowledgment.
From c-relay-pg:
- The main thread runs the libwebsockets service loop (
lws_service()with 1000ms timeout) instart_websocket_relay(). ingest_event()is the shared entry point for non-client event ingestion. It performs signature/structure/expiration/PoW validation, stores through the writer thread pool, and pushes a completion record to a queue drained on the lws thread byprocess_inbox_event_completions().- The
EVENT_SOURCE_CACHING_INBOXsource mode already implements the exact semantics UDP needs: nowsi/pss, no OK response, no admin command execution (kind 23456 stored as data), still validates and broadcasts. - The caching inbox poller (
src/caching_inbox_poller.c) runs on the main lws thread and callsingest_event()directly — it does NOT use a separate thread. The UDP listener CANNOT do this becauserecvfrom()is blocking.
Architecture
flowchart TD
subgraph "UDP Sender (any client)"
S[ nak event ... | python udp_nostr_send.py ]
end
subgraph "c-relay-pg process"
subgraph "UDP Listener Thread"
UDPL[udp_ingress_thread<br/>blocking recvfrom on UDP port]
Q[ring buffer / queue<br/>thread-safe]
end
subgraph "Main lws Thread"
TICK[udp_ingress_tick<br/>drains queue each loop iteration]
INGEST[ingest_event<br/>EVENT_SOURCE_UDP_INGRESS]
VAL[nostr_validate_unified_request<br/>signature + structure + PoW + expiration]
STORE[store_event_core<br/>via writer thread pool]
COMP[external_ingress_completion_queue]
BCAST[broadcast_event_to_subscriptions]
end
subgraph "Writer Thread Pool"
WP[DB insert]
end
end
S -- "single UDP datagram ≤1472B" --> UDPL
UDPL -- "enqueue raw JSON" --> Q
TICK -- "dequeue batch" --> INGEST
INGEST --> VAL
VAL --> STORE
STORE --> WP
WP -- "completion" --> COMP
TICK2[process_external_ingress_completions] -- "drain" --> COMP
TICK2 --> BCAST
Threading Model
| Component | Thread | Why |
|---|---|---|
UDP recvfrom() loop |
Dedicated pthread | recvfrom() is blocking; cannot run on lws main thread without starving the WebSocket service loop |
Queue drain + ingest_event() |
Main lws thread | ingest_event() pushes completions to a queue that must be drained on the lws thread for broadcast. Calling ingest_event() from the UDP thread is safe (it uses the thread pool for DB writes and atomically pushes completions), but draining must happen on lws-main. Decision: call ingest_event() from the UDP thread directly (matches how the caching inbox poller calls it, just from a different thread). The completion queue is already thread-safe (mutex-protected). |
| Completion drain + broadcast | Main lws thread | broadcast_event_to_subscriptions() must run on lws-main |
Revised simpler approach: The UDP thread calls ingest_event() directly after
receiving a datagram. ingest_event() validates, stores via the writer thread pool,
and pushes a completion record to the (mutex-protected) completion queue. The main lws
thread drains completions each iteration (as it already does for caching inbox). This
avoids an intermediate queue entirely — the completion queue IS the handoff mechanism.
flowchart LR
subgraph "UDP Thread"
RECV[recvfrom] --> INGEST[ingest_event]
end
subgraph "Main lws Thread"
DRAIN[process_external_ingress_completions] --> BCAST[broadcast]
end
INGEST -- "mutex-protected push" --> CQ[completion queue]
CQ -- "mutex-protected pop" --> DRAIN
INGEST -- "wake lws" --> WAKE[lws_cancel_service]
Why a dedicated thread (not lws integration)
libwebsockets can integrate foreign sockets into its event loop via
lws_add_fd() / lws_foreign_callback(), but:
- The static MUSL build uses a minimal lws config — foreign socket support may not be compiled in.
- lws integration would require non-blocking
recvfrom()and edge-triggered handling, adding complexity for no benefit (UDP events are low-frequency). - A dedicated thread with blocking
recvfrom()is simpler, more portable, and matches the "stateless, fire-and-forget" philosophy — the thread does one thing and does it well. - The thread calls
lws_cancel_service(ws_context)after pushing a completion to wake the main loop for prompt broadcast (same pattern aswake_event_loop_from_thread_pool()).
Shared Abstraction (for future DNS relay)
To allow the future authoritative DNS relay to reuse the same infrastructure, we generalize the existing "caching inbox completion queue" into an "external ingress completion queue" that any non-client ingress source can use.
Rename / generalize
| Current | New (generalized) |
|---|---|
inbox_event_completion_t |
external_ingress_completion_t |
g_inbox_completion_mutex |
g_external_ingress_completion_mutex |
process_inbox_event_completions() |
process_external_ingress_completions() |
g_inbox_import_accepted/rejected/duplicates |
Per-source counters (see below) |
The completion struct and queue logic are identical — only the names change. The caching inbox poller, UDP listener, and future DNS listener all push completions to the same queue. The main thread drains all of them in one pass.
Per-source counters
Instead of global g_inbox_import_* counters, add per-source counters so the admin
status page can distinguish UDP vs caching-inbox vs DNS:
typedef enum {
INGRESS_SOURCE_CACHING_INBOX = 0,
INGRESS_SOURCE_UDP,
INGRESS_SOURCE_DNS, // future
INGRESS_SOURCE_COUNT
} ingress_source_t;
static long long g_ingress_accepted[INGRESS_SOURCE_COUNT];
static long long g_ingress_rejected[INGRESS_SOURCE_COUNT];
static long long g_ingress_duplicates[INGRESS_SOURCE_COUNT];
The completion struct gains an ingress_source_t source field so
process_external_ingress_completions() knows which counter to increment.
New event_source_t enum value
Add to src/main.h:
typedef enum {
EVENT_SOURCE_CLIENT,
EVENT_SOURCE_CACHING_INBOX,
EVENT_SOURCE_UDP_INGRESS, // NEW
EVENT_SOURCE_DNS_INGRESS // NEW (future, stub for now)
} event_source_t;
In ingest_event(), EVENT_SOURCE_UDP_INGRESS follows the same code path as
EVENT_SOURCE_CACHING_INBOX (no wsi/pss, no admin execution, no OK response). The
difference is only the counter routing and the source field in the completion record.
Implementation Steps
Step 1: Generalize the completion queue
Files: src/main.c, src/main.h
- Rename
inbox_event_completion_t→external_ingress_completion_tand add aningress_source_t sourcefield. - Rename the mutex, head/tail pointers, and push/pop functions accordingly.
- Rename
process_inbox_event_completions()→process_external_ingress_completions(). - Replace the global
g_inbox_import_*counters with the per-source arrayg_ingress_accepted/rejected/duplicates[INGRESS_SOURCE_COUNT]. - Update the declaration in
main.h. - Update the call site in
src/websockets.cto callprocess_external_ingress_completions(). - Update
ingest_event()to accept the new source types and route to the correct counter.EVENT_SOURCE_UDP_INGRESSandEVENT_SOURCE_DNS_INGRESSuse the same no-session logic asEVENT_SOURCE_CACHING_INBOX.
Note: The #ifdef DB_BACKEND_POSTGRES guards around the completion queue remain —
the project is PostgreSQL-only, and the completion/broadcast mechanism depends on the
thread pool writer. For SQLite builds (which are no longer supported but still
compile), UDP ingress will validate and store synchronously but not broadcast (same as
current caching inbox behavior on SQLite).
Step 2: Create the UDP ingress module
New files: src/udp_ingress.h, src/udp_ingress.c
src/udp_ingress.h
#ifndef UDP_INGRESS_H
#define UDP_INGRESS_H
// Initialize the UDP ingress listener. Binds the UDP socket and starts
// the receiver thread. Returns 0 on success, -1 on error.
int udp_ingress_init(void);
// Shut down the UDP listener: closes socket, joins thread.
void udp_ingress_shutdown(void);
// Get statistics for admin status display (all out-params optional).
void udp_ingress_get_stats(long long* out_total_received,
long long* out_total_accepted,
long long* out_total_rejected,
long long* out_total_duplicates,
long long* out_total_oversized,
long long* out_total_parse_errors);
#endif // UDP_INGRESS_H
src/udp_ingress.c — core logic
// Configuration keys (read at init, re-read each tick is unnecessary for UDP)
#define CFG_KEY_UDP_ENABLED "udp_ingress_enabled"
#define CFG_KEY_UDP_PORT "udp_ingress_port"
#define CFG_KEY_UDP_BIND_ADDR "udp_ingress_bind_addr"
#define CFG_KEY_UDP_MAX_DGRAM "udp_ingress_max_datagram_size"
// Defaults
#define DEFAULT_UDP_PORT 8888 // same as WS port (TCP/UDP are separate namespaces)
#define DEFAULT_UDP_BIND_ADDR "0.0.0.0"
#define DEFAULT_UDP_MAX_DGRAM 1472 // single-packet guarantee
// Thread state
static pthread_t g_udp_thread;
static int g_udp_sock_fd = -1;
static volatile int g_udp_running = 0;
// Statistics (atomic counters)
static long long g_total_received = 0;
static long long g_total_oversized = 0;
static long long g_total_parse_errors = 0;
Receiver thread function:
static void* udp_ingress_thread_fn(void* arg) {
pthread_setname_np(pthread_self(), "udp-ingress");
while (g_udp_running) {
unsigned char buf[DEFAULT_UDP_MAX_DGRAM + 1];
struct sockaddr_storage src_addr;
socklen_t addr_len = sizeof(src_addr);
ssize_t n = recvfrom(g_udp_sock_fd, buf, DEFAULT_UDP_MAX_DGRAM, 0,
(struct sockaddr*)&src_addr, &addr_len);
if (n < 0) {
if (errno == EINTR) continue;
if (!g_udp_running) break; // shutdown
DEBUG_WARN("udp_ingress: recvfrom error: %s", strerror(errno));
continue;
}
__sync_fetch_and_add(&g_total_received, 1);
// Single-packet guarantee: silently drop oversized datagrams.
// (The kernel already caps at max_datagram_size, but log for visibility.)
if (n > DEFAULT_UDP_MAX_DGRAM) {
__sync_fetch_and_add(&g_total_oversized, 1);
continue;
}
// Null-terminate for JSON parsing.
buf[n] = '\0';
// Feed through the shared ingestion pipeline.
// ingest_event() does: duplicate check → signature validation →
// structure/expiration/PoW validation → store_event_core() →
// push completion to external ingress queue → wake lws.
int rc = ingest_event((const char*)buf, (size_t)n,
EVENT_SOURCE_UDP_INGRESS, NULL, NULL);
// rc == 0: accepted (or duplicate). rc < 0: rejected (invalid sig, etc.)
// No response is sent — silent drop on failure. This is the no-handshake
// property: the relay's response to an invalid event is indistinguishable
// from a server that isn't running Nostr at all.
(void)rc;
}
return NULL;
}
Init function:
int udp_ingress_init(void) {
int enabled = get_config_bool(CFG_KEY_UDP_ENABLED, 0);
if (!enabled) {
DEBUG_LOG("udp_ingress: disabled in config");
return 0;
}
int port = get_config_int(CFG_KEY_UDP_PORT, DEFAULT_UDP_PORT);
const char* bind_addr = get_config_value(CFG_KEY_UDP_BIND_ADDR);
if (!bind_addr) bind_addr = DEFAULT_UDP_BIND_ADDR;
// Create UDP socket
g_udp_sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (g_udp_sock_fd < 0) {
DEBUG_ERROR("udp_ingress: socket() failed: %s", strerror(errno));
return -1;
}
// Set SO_REUSEADDR so we can rebind quickly after restart
int opt = 1;
setsockopt(g_udp_sock_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// Bind
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, bind_addr, &addr.sin_addr);
if (bind(g_udp_sock_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
DEBUG_ERROR("udp_ingress: bind(%s:%d) failed: %s", bind_addr, port, strerror(errno));
close(g_udp_sock_fd);
g_udp_sock_fd = -1;
return -1;
}
// Start receiver thread
g_udp_running = 1;
if (pthread_create(&g_udp_thread, NULL, udp_ingress_thread_fn, NULL) != 0) {
DEBUG_ERROR("udp_ingress: failed to create receiver thread");
close(g_udp_sock_fd);
g_udp_sock_fd = -1;
g_udp_running = 0;
return -1;
}
DEBUG_INFO("udp_ingress: listening on udp://%s:%d (max datagram %d bytes)",
bind_addr, port, DEFAULT_UDP_MAX_DGRAM);
return 0;
}
Shutdown function:
void udp_ingress_shutdown(void) {
if (!g_udp_running) return;
g_udp_running = 0;
if (g_udp_sock_fd >= 0) {
shutdown(g_udp_sock_fd, SHUT_RDWR); // unblock recvfrom()
close(g_udp_sock_fd);
g_udp_sock_fd = -1;
}
pthread_join(g_udp_thread, NULL);
DEBUG_INFO("udp_ingress: shutdown (received=%lld accepted=%lld rejected=%lld)",
g_total_received, ...);
}
Step 3: Add configuration keys
File: src/default_config_event.h
Add after the caching config block:
// UDP Ingress Settings (no-handshake event reception)
{"udp_ingress_enabled", "false"}, // Enable UDP event listener
{"udp_ingress_port", "443"}, // UDP port 443 (blends with QUIC/HTTP3; requires CAP_NET_BIND_SERVICE)
{"udp_ingress_bind_addr", "0.0.0.0"}, // Bind address
{"udp_ingress_max_datagram_size", "1472"}, // Max datagram size (single-packet guarantee)
These are dynamic config keys (no restart required for enable/disable — see Step 5).
Step 4: Wire into main startup/shutdown
File: src/main.c
- Add
#include "udp_ingress.h"at the top. - After
caching_inbox_poller_init()(line ~2967), add:// Initialize the UDP ingress listener (if enabled in config). if (udp_ingress_init() != 0) { DEBUG_WARN("UDP ingress listener failed to start; continuing without UDP"); } - Before
caching_inbox_poller_shutdown()(line ~3051), add:udp_ingress_shutdown();
Step 5: Dynamic enable/disable (hot config reload)
The UDP listener should start/stop when udp_ingress_enabled changes in config,
without requiring a relay restart. Two approaches:
Option A (simple, recommended for v1): Read udp_ingress_enabled only at startup.
Changing it requires a restart. This matches how most config keys work and is the
least complex.
Option B (hot reload): Add a config-change handler that starts/stops the UDP thread when the config value changes. This requires:
- A check in the main lws loop (like
caching_inbox_poller_tick()does forcaching_inbox_enabled) that compares the current enabled state vs the config value and starts/stops the thread accordingly. - Thread start/stop must be safe (no race with
recvfrom()).
Decision: Start with Option A. Add a udp_ingress_tick() function called from the
lws main loop that checks if the enabled config has changed and starts/stops the
thread. This is low-cost (one get_config_bool() per loop iteration) and matches the
caching inbox poller pattern. The tick function:
void udp_ingress_tick(void) {
int want_enabled = get_config_bool(CFG_KEY_UDP_ENABLED, 0);
if (want_enabled && !g_udp_running) {
// Config enabled but thread not running — start it.
udp_ingress_init();
} else if (!want_enabled && g_udp_running) {
// Config disabled but thread running — stop it.
udp_ingress_shutdown();
}
}
Add udp_ingress_tick() call in the lws main loop in
src/websockets.c after caching_inbox_poller_tick().
Step 6: Update build system
Files: Makefile, Dockerfile.alpine-musl
- Add
src/udp_ingress.ctoMAIN_SRCin the Makefile. - Add
src/udp_ingress.cto thegccsource list in the Dockerfile.
No new external libraries are needed — UDP uses standard POSIX sockets
(<sys/socket.h>, <netinet/in.h>, <arpa/inet.h>) which are already included in
src/main.c and available in the MUSL static build.
Step 7: Admin status / monitoring
File: src/api.c (status event generation)
Add UDP ingress stats to the relay status event (kind 33334 or similar) so the admin page can display:
- UDP ingress: enabled/disabled
- Total received, accepted, rejected, duplicates, oversized, parse errors
- Bind address and port
Expose udp_ingress_get_stats() and call it from the status generation code.
Step 8: Tests
New file: tests/udp_ingress_test.sh
Local testing on port 8888
The relay is built and started with make_and_restart_relay.sh, which defaults to
port 8888. This does not interfere with the local production relay running on
port 7777 — the script matches processes by port (pgrep -f "c_relay_pg_.*-p 8888")
and only kills/restarts the 8888 instance.
Since TCP and UDP are separate port namespaces, the UDP listener on port 8888 coexists with the WebSocket TCP listener on port 8888 — no conflict.
Test sequence:
-
Build and start relay with UDP ingress enabled:
# Set udp_ingress_enabled=true and udp_ingress_port=8888 in config, # then build and restart: ./make_and_restart_relay.sh(The config can be set via the admin API or by adding the key to
default_config_event.hdefaults before building.) -
Send a valid signed event via UDP using the existing
udp_nostr_send.py:# Simplest: nak + nc (nak uses a default key, no --sec needed) nak event -k 1 -c "Hello via UDP Nostr!" | nc -u -w1 127.0.0.1 8888 # Or with Python (explicit key generation): nak event -k 1 -c "Hello via UDP Nostr!" --sec $(nak key generate) \ | python3 ~/lt/udp_nostr/udp_nostr_send.py 127.0.0.1 8888 -
Verify the event appears in the relay — subscribe via WebSocket:
wscat -c ws://localhost:8888 # Send: ["REQ","test",{"kinds":[1],"limit":1}] # Verify the UDP-sent event is returned -
Send an invalid event (bad signature) — verify it is silently dropped:
echo '{"id":"000...","pubkey":"000...","created_at":1,"kind":1,"tags":[],"content":"fake","sig":"000..."}' \ | nc -u -w1 127.0.0.1 8888 # No response, no storage. Check relay.log for the rejection debug line. -
Send an oversized datagram (>1472 bytes) — verify it is dropped:
python3 -c "print('A'*2000)" | nc -u -w1 127.0.0.1 8888 # Silently dropped (oversized counter increments). -
Send a duplicate event — verify it is not stored twice (duplicate counter increments, no broadcast).
-
Check stats in relay.log — the UDP ingress thread logs received/accepted/ rejected/oversized counts on shutdown and at debug level.
Security Considerations
| Concern | Mitigation |
|---|---|
| Flooding | UDP has no congestion control. Mitigate with: (1) per-pubkey rate limiting (future config key udp_ingress_rate_limit_per_pubkey), (2) the existing PoW requirement (pow_min_difficulty config key applies to all ingress sources via nostr_validate_unified_request), (3) signature verification cost is a natural rate limiter (~μs per event). |
| Amplification | Not a vector — the relay never responds to UDP datagrams. The response is smaller than the request (zero bytes). |
| Active probing | The relay does not respond to invalid events. A probe receives nothing, indistinguishable from a non-Nostr UDP service. |
| IP spoofing | Not relevant — the relay does not respond, so spoofed source IPs do not cause reflected traffic. The event is self-validating (signature), so the source IP is irrelevant to event validity. |
| Port conflict | TCP and UDP port namespaces are separate. UDP port 8888 does not conflict with the WebSocket TCP port 8888. Configurable via udp_ingress_port. |
| Privileged ports | If udp_ingress_port is set to <1024 (e.g., 443 for QUIC-blending), the binary needs CAP_NET_BIND_SERVICE. The build script or systemd unit should set this via setcap. |
Future: Authoritative DNS Relay Ingress
The DNS relay (planned in ~/lt/authoritative_dns_relay) will receive events encoded
in DNS query subdomain labels. It will reuse this infrastructure as follows:
- Same
event_source_t:EVENT_SOURCE_DNS_INGRESS(already added in Step 1). - Same completion queue: DNS events push to
external_ingress_completion_twithsource = INGRESS_SOURCE_DNS. - Same
ingest_event()path: identical validation, storage, and broadcast. - New module:
src/dns_ingress.c/src/dns_ingress.h— a UDP listener on port 53 that parses DNS query headers, extracts the base64url-encoded event from the subdomain label, decodes it, and callsingest_event(). - New config keys:
dns_ingress_enabled,dns_ingress_port(default 53),dns_ingress_domain(the authoritative domain to match). - Multi-query reassembly: For events split across multiple DNS queries, a
session-based reassembly buffer (timeout-driven) will be needed in
dns_ingress.c. This is DNS-specific and does not affect the shared completion queue.
The shared abstraction means the DNS relay implementation only needs to handle:
- DNS protocol parsing (header, question section, label extraction)
- Base64url decoding
- Multi-query reassembly
- NXDOMAIN response generation
Everything else (validation, storage, broadcast, completion queue, stats) is inherited.
File Change Summary
| File | Change |
|---|---|
src/main.h |
Add EVENT_SOURCE_UDP_INGRESS, EVENT_SOURCE_DNS_INGRESS to enum. Rename process_inbox_event_completions → process_external_ingress_completions. Add ingress_source_t enum. |
src/main.c |
Generalize completion queue (rename + per-source counters). Handle new source types in ingest_event(). Add #include "udp_ingress.h". Call udp_ingress_init() / udp_ingress_shutdown(). |
src/websockets.c |
Update completion drain call to process_external_ingress_completions(). Add udp_ingress_tick() call in main loop. |
src/udp_ingress.h |
NEW — UDP ingress public API. |
src/udp_ingress.c |
NEW — UDP socket, receiver thread, ingest_event() call, stats, hot-reload tick. |
src/default_config_event.h |
Add udp_ingress_* config keys. |
src/api.c |
Add UDP ingress stats to status event. |
Makefile |
Add src/udp_ingress.c to MAIN_SRC. |
Dockerfile.alpine-musl |
Add src/udp_ingress.c to gcc source list. |
tests/udp_ingress_test.sh |
NEW — end-to-end test using udp_nostr_send.py or nc -u. |
Deployment Decisions (laantungir.net/relay)
Port: UDP 443
Decision: default port 443. Verified on the production relay:
- UDP 443 is free — nginx listens on TCP 443 only (no HTTP/3/QUIC configured).
- TCP and UDP are separate port namespaces, so UDP 443 coexists with nginx's TCP 443.
- UDP on port 443 is indistinguishable from QUIC/HTTP3 traffic to a passive
observer — the strongest censorship-resistance position from the
protocol_hardening.mdanalysis. - Port 443 is universally allowed through firewalls; non-standard UDP ports may be blocked by some cloud providers or middleboxes.
Privileged port binding (CAP_NET_BIND_SERVICE)
Port 443 is <1024, so the relay binary needs permission to bind. The systemd service
runs as user c-relay-pg (non-root). Two options:
Option A (recommended): systemd AmbientCapabilities=CAP_NET_BIND_SERVICE
Add to the [Service] section of
/etc/systemd/system/c-relay-pg.service:
AmbientCapabilities=CAP_NET_BIND_SERVICE
This grants the service the capability to bind privileged ports without running as
root. No changes to the binary or filesystem needed. This is the cleanest approach
and works with the existing NoNewPrivileges=true security setting.
Option B: setcap on the binary
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/c_relay_pg/c_relay_pg
This persists the capability on the binary file. Must be re-applied after every binary update (the deploy script should include this).
Decision: Option A (systemd AmbientCapabilities) — survives binary updates automatically and is the standard systemd pattern.
Firewall (UFW)
If UFW is enabled on the relay, UDP 443 must be opened:
sudo ufw allow 443/udp
Check current status: sudo ufw status. If UFW is not active, no change needed
(the relay already accepts TCP 8888 and TCP 443).
systemd service changes
The existing RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 already permits
UDP sockets (UDP uses AF_INET/AF_INET6). No change needed there.
The only systemd change is adding AmbientCapabilities=CAP_NET_BIND_SERVICE to
the [Service] section.
Config key update
Update the default in src/default_config_event.h:
{"udp_ingress_port", "443"}, // UDP port 443 (blends with QUIC/HTTP3)
Open Questions (resolved)
-
Default port: 443 — blends with QUIC/HTTP3 traffic, universally allowed, verified free on the production relay. Requires
CAP_NET_BIND_SERVICEvia systemdAmbientCapabilities. -
IPv6 support: IPv4-only for v1. The udp_nostr project is IPv4-only. Add IPv6 (
AF_INET6+ dual-stackIPV6_V6ONLY=0) as a follow-up. Note: nginx listens on both IPv4 and IPv6 TCP 443, so an IPv6 UDP listener on 443 would also be safe. -
Rate limiting: Defer for v1. The PoW requirement (
pow_min_difficultyconfig key) and signature verification cost provide natural rate limiting. Add explicit per-pubkey rate limiting if flooding becomes a problem in production.