Files
c-relay-pg/plans/udp_ingress_integration_plan.md
T

28 KiB
Raw Blame History

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) in start_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 by process_inbox_event_completions().
  • The EVENT_SOURCE_CACHING_INBOX source mode already implements the exact semantics UDP needs: no wsi/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 calls ingest_event() directly — it does NOT use a separate thread. The UDP listener CANNOT do this because recvfrom() 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:

  1. The static MUSL build uses a minimal lws config — foreign socket support may not be compiled in.
  2. lws integration would require non-blocking recvfrom() and edge-triggered handling, adding complexity for no benefit (UDP events are low-frequency).
  3. 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.
  4. The thread calls lws_cancel_service(ws_context) after pushing a completion to wake the main loop for prompt broadcast (same pattern as wake_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

  1. Rename inbox_event_completion_t → external_ingress_completion_t and add an ingress_source_t source field.
  2. Rename the mutex, head/tail pointers, and push/pop functions accordingly.
  3. Rename process_inbox_event_completions() → process_external_ingress_completions().
  4. Replace the global g_inbox_import_* counters with the per-source array g_ingress_accepted/rejected/duplicates[INGRESS_SOURCE_COUNT].
  5. Update the declaration in main.h.
  6. Update the call site in src/websockets.c to call process_external_ingress_completions().
  7. Update ingest_event() to accept the new source types and route to the correct counter. EVENT_SOURCE_UDP_INGRESS and EVENT_SOURCE_DNS_INGRESS use the same no-session logic as EVENT_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

  1. Add #include "udp_ingress.h" at the top.
  2. 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");
    }
    
  3. 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 for caching_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

  1. Add src/udp_ingress.c to MAIN_SRC in the Makefile.
  2. Add src/udp_ingress.c to the gcc source 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:

  1. 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.h defaults before building.)

  2. 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
    
  3. 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
    
  4. 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.
    
  5. 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).
    
  6. Send a duplicate event — verify it is not stored twice (duplicate counter increments, no broadcast).

  7. 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:

  1. Same event_source_t: EVENT_SOURCE_DNS_INGRESS (already added in Step 1).
  2. Same completion queue: DNS events push to external_ingress_completion_t with source = INGRESS_SOURCE_DNS.
  3. Same ingest_event() path: identical validation, storage, and broadcast.
  4. 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 calls ingest_event().
  5. New config keys: dns_ingress_enabled, dns_ingress_port (default 53), dns_ingress_domain (the authoritative domain to match).
  6. 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.md analysis.
  • 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)

  1. Default port: 443 — blends with QUIC/HTTP3 traffic, universally allowed, verified free on the production relay. Requires CAP_NET_BIND_SERVICE via systemd AmbientCapabilities.

  2. IPv6 support: IPv4-only for v1. The udp_nostr project is IPv4-only. Add IPv6 (AF_INET6 + dual-stack IPV6_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.

  3. Rate limiting: Defer for v1. The PoW requirement (pow_min_difficulty config key) and signature verification cost provide natural rate limiting. Add explicit per-pubkey rate limiting if flooding becomes a problem in production.