24 KiB
Plan: UDP Nostr Relay on Cloud-Provider Load-Balancer IPs
Goal
Make a UDP Nostr relay's IP painful to block by placing it behind a cloud-provider load balancer whose IP sits in a range shared across many customers, and useless to block by giving the relay mobility via FIPS discovery. The no-handshake property of UDP Nostr (docs/no_handshake.md) is preserved throughout.
This plan focuses specifically on cloud-provider shared load-balancer IPs — AWS, GCP, and Azure load balancers and serverless front-ends that sit on IPs from the provider's shared pools.
The principle, and the honest collateral model
The Cloudflare/Great Firewall observation in this project (README.md, docs/mtu_exploration.md, docs/protocol_hardening.md) is one principle:
Make your message look exactly like the most common, most essential traffic on the network. When you do this, the adversary cannot block you without blocking everything.
Cloudflare is the famous instance: its anycast edge IPs front hundreds of thousands of unrelated customer domains from the same IP address. Blocking one Cloudflare IP is politically untenable because it breaks all those customers at once. That is per-IP collateral — one IP, many independent users.
Cloud-provider load balancers work differently, and it is important to be honest about the distinction:
| Collateral type | How it works | Example | Strength |
|---|---|---|---|
| Per-IP collateral | One IP serves many unrelated customers simultaneously | Cloudflare anycast, GCP HTTP(S) LB anycast | Very high — blocking one IP breaks many independent users |
| Range-level collateral | Your IP is dedicated to you, but lives in a provider IP range shared by millions of services | AWS NLB, AWS Global Accelerator, Azure LB | High at the range level — blocking the range breaks millions of services, but blocking your single IP breaks only you |
The honest assessment: most cloud load balancers that support UDP give you dedicated IPs (per-customer), not shared IPs. The collateral is at the range level: your IP lives in AWS's / GCP's / Azure's IP space, and blocking that range breaks an enormous number of legitimate services. A regime that wants to block your specific relay IP can do so with low collateral (it's your IP alone), but a regime that blocks by CIDR range (the common approach for blocking cloud-hosted proxies/VPNs) faces extreme collateral damage.
This means the cloud-LB approach provides:
- Protection against range-based blocking. Regimes that block cloud IP ranges (a common tactic) cannot block your relay without blocking the entire cloud provider — which breaks millions of services. This is the primary defense.
- No protection against targeted IP blocking. If the regime identifies your specific NLB IP and blocks just that IP, only your relay goes down. This is where mobility (the second layer of this plan) becomes essential — the relay moves to a new IP in the same or a different cloud provider's range.
Why this is still worth doing
Despite the per-IP limitation, the cloud-LB approach has real advantages over a bare VPS:
- Cloud IP ranges are extremely high collateral. AWS, GCP, and Azure collectively host a huge fraction of the internet. Many regimes already struggle with this — they want to block cloud-hosted circumvention tools but blocking AWS ranges breaks too many legitimate services. This is documented in the censorship resistance literature: the GFW partially blocks some cloud ranges but cannot fully block them without unacceptable damage.
- Anycast IPs (AWS Global Accelerator, GCP Network LB) are harder to target. They're advertised from multiple edge locations via BGP. A regime must block the IP at all edge locations or accept partial reachability.
- IP mobility is built-in. Cloud LBs let you move IPs, change backends, and redeploy in different regions without changing the client-facing endpoint. This composes naturally with the FIPS discovery plan.
- The IP looks like legitimate cloud traffic. UDP to an AWS NLB IP is indistinguishable from UDP to any other AWS-hosted UDP service (gaming servers, VoIP, IoT). Combined with the no-handshake property, there is nothing to fingerprint.
Which cloud load balancers support UDP
| Provider | Load balancer | UDP support | IP model | Anycast? |
|---|---|---|---|---|
| AWS | NLB (Network Load Balancer) | ✅ Yes | Static IPs per AZ (dedicated to you) | No (per-AZ unicast) |
| AWS | Global Accelerator | ✅ Yes | 2 anycast IPs from AWS pool (dedicated to you) | Yes (BGP anycast from AWS edge) |
| AWS | ALB (Application LB) | ❌ No (HTTP/HTTPS only) | — | — |
| AWS | CloudFront | ❌ No (HTTP/HTTPS only) | — | — |
| GCP | Network Load Balator (pass-through) | ✅ Yes | Anycast IP (regional) or unicast | Yes (regional anycast) |
| GCP | Global External HTTP(S) LB | ❌ No (TCP only) | Anycast (shared pool) | Yes |
| Azure | Load Balancer (Standard) | ✅ Yes | Frontend IP (dedicated to you) | No |
| Azure | Front Door | ❌ No (HTTP/HTTPS only) | — | — |
The UDP-capable options are: AWS NLB, AWS Global Accelerator, GCP Network LB, and Azure Load Balancer. All provide dedicated IPs (not per-IP shared like Cloudflare). The collateral is range-level.
Recommended: AWS Global Accelerator
AWS Global Accelerator is the strongest option for this use case because:
- Anycast IPs. You get two anycast IPs that are advertised from AWS's global edge network via BGP. Traffic enters at the nearest edge and traverses AWS's private backbone to your backend. A regime must block the IP at all edge locations or accept partial reachability.
- UDP support. Global Accelerator supports UDP listeners.
- IP is from AWS's anycast pool. The IP is in AWS's IP space, which is extremely high collateral at the range level.
- Backend mobility. You can change the backend endpoint (your relay host) without changing the client-facing anycast IPs. The relay can move behind the same accelerator.
- Health checks. Global Accelerator routes traffic only to healthy backends. If your relay goes down, traffic can route to a standby.
Cost consideration: Global Accelerator has a fixed hourly charge (~$0.025/hr for the accelerator) plus data transfer premiums. This is more expensive than a bare NLB but the anycast routing and backend mobility are worth it for this use case.
Alternative: AWS NLB (cheaper, no anycast)
If cost is a concern, an AWS NLB provides UDP support with static IPs per AZ. The IPs are dedicated to you but in AWS's IP range. You lose anycast routing (the IP is unicast in one region), but you keep range-level collateral and UDP support. The NLB is cheaper (~$0.022/hr plus LCU charges).
Alternative: GCP Network Load Balancer
GCP's pass-through Network LB supports UDP and provides an anycast IP (regional). Similar collateral model to AWS NLB. Choose this if you already use GCP.
Architecture
flowchart LR
SENDER["Sender<br/>nak + udp_nostr_send.py"]
NPOOL["Nostr relay pool<br/>wss://relay.damus.io etc."]
subgraph AWS["AWS Global Accelerator"]
ANYCAST["Anycast IPs<br/>from AWS pool<br/>UDP listener"]
BACKEND["Backend target group<br/>relay host:8889"]
end
subgraph RelayHost["Relay Host - EC2 or elsewhere"]
NRELAY["UDP Nostr receiver<br/>udp_nostr_recv.py<br/>port 8889"]
ADVERT["Advert publisher<br/>kind 37195<br/>re-publishes on endpoint change"]
end
SENDER -- "1. nak req -k 37195 -a relay_npub" --> NPOOL
NPOOL -- "2. advert: udp endpoint<br/>anycast_ip:port" --> SENDER
SENDER -- "3. single UDP datagram<br/>no handshake" --> ANYCAST
ANYCAST -- "4. routes over AWS backbone" --> BACKEND
BACKEND --> NRELAY
ADVERT -- "publishes endpoint" --> NPOOL
style ANYCAST fill:#4a4,color:#fff
style NRELAY fill:#48a,color:#fff
style ADVERT fill:#a84,color:#fff
What runs where
| Component | Where | Purpose |
|---|---|---|
| AWS Global Accelerator | AWS | Anycast UDP listener, routes to backend, provides range-level collateral IP |
UDP Nostr receiver (src/udp_nostr_recv.py) |
EC2 or any reachable host | The relay data plane (unchanged) |
Advert publisher (plans/udp_nostr_fips_discovery.md Step 1) |
Relay host | Publishes kind 37195 with the accelerator's anycast endpoint |
| Sender resolver + sender | Sender's machine | From FIPS plan: resolve npub → endpoint, send UDP datagram |
What changes vs. a bare UDP Nostr relay
| Component | Bare relay | This plan |
|---|---|---|
| UDP listener | Direct on host IP | Behind AWS Global Accelerator anycast IP |
| Client-facing IP | Host's IP (low collateral) | AWS anycast IP (range-level collateral) |
| Endpoint discovery | Hardcoded IP | FIPS kind 37195 advert, npub is stable address |
| IP mobility | Static | Backend can change without changing client IP; or relay moves to new accelerator + re-advertises |
| Routing | Direct path | Enters at AWS edge, traverses AWS backbone |
What this gains
- Range-level collateral. The anycast IP is in AWS's IP space. Blocking it by CIDR range breaks millions of AWS-hosted services. Regimes that block cloud ranges face extreme collateral damage; regimes that block by specific IP can target you, but must first identify the IP (which moves with mobility).
- Anycast hardening. The anycast IP is advertised from multiple AWS edge locations. A regime must block it at all edges or accept partial reachability. This is harder than blocking a unicast IP in one location.
- Backend mobility without client change. Global Accelerator lets you swap the backend target (your relay host) without changing the client-facing anycast IP. The relay can move to a new EC2 instance, a new region, or even a non-AWS host, and the sender never notices — the anycast IP stays the same.
- AWS backbone transit. Traffic enters the AWS network at the edge and traverses AWS's private backbone to the backend. The relay host's actual IP is not visible to the sender or to border DPI at the sender's side — only the AWS anycast IP is visible.
- No-handshake preserved. The UDP datagram passes through the accelerator transparently. No TLS, no handshake, no protocol fingerprint. The accelerator is a pass-through UDP forwarder, not a proxy that terminates connections.
What it costs
- AWS Global Accelerator pricing. Fixed hourly charge (
$0.025/hr ≈ $18/month) plus data transfer premiums ($0.025/GB for standard, more for cross-region). For a low-bandwidth Nostr relay (events are <1472 bytes each), data costs are negligible. The fixed cost is the main expense. - AWS account required. The operator needs an AWS account. This is a KYC/identity consideration — AWS knows who you are. Mitigation: the relay host (backend) can be outside AWS; only the accelerator front-end is on AWS. But the accelerator is tied to an AWS account.
- No per-IP collateral. As discussed: the anycast IP is dedicated to your accelerator. A regime that identifies and blocks just that IP breaks only your relay. The defense is range-level collateral + mobility, not per-IP sharing.
- UDP pass-through limitations. Global Accelerator forwards UDP datagrams but does not guarantee delivery (UDP is unreliable). If a datagram is lost between the edge and the backend, it's gone. This is already the UDP Nostr model (fire-and-forget), so it's not a new limitation.
- Potential for AWS to terminate the account. AWS could shut down the accelerator if they receive abuse complaints. Mitigation: the relay is a passive receiver that drops invalid events silently — there's nothing for AWS to detect as abuse. The relay doesn't send responses, doesn't host content, doesn't serve requests. It's a black hole that occasionally stores a valid Nostr event.
Implementation steps
Step 1 — Set up the relay backend
Run the existing UDP Nostr receiver on a host reachable from AWS. This can be:
- An EC2 instance in a public subnet (simplest)
- An EC2 instance in a private subnet with a NAT gateway (hides the backend IP)
- A non-AWS host with a public IP (the accelerator routes to it over the internet)
python3 src/udp_nostr_recv.py 0.0.0.0 8889
The backend must be reachable on UDP 8889 from the Global Accelerator's routing. If using EC2, configure the security group to allow inbound UDP 8889 from anywhere (the accelerator's traffic comes from AWS internal ranges, but allowing from anywhere is simpler and doesn't hurt — the relay drops invalid events silently).
Deliverable: running udp_nostr_recv.py on a backend host, reachable on UDP 8889.
Step 2 — Create the AWS Global Accelerator
- In the AWS Console (or via CLI/terraform), create a Global Accelerator.
- Add a UDP listener on port 8889 (or 443 if you want to blend with QUIC — see open question 3).
- Create an endpoint group in one or more AWS regions.
- Add the relay backend as an endpoint (by IP address or ALB/NLB ARN if fronting an NLB).
- Configure a health check (UDP health checks are supported — the accelerator sends a UDP probe and expects a response; since the relay drops invalid events silently, use a custom health check endpoint or set the health check to TCP on a separate port if the backend also runs a TCP service).
Note on health checks: Global Accelerator requires healthy endpoints to route traffic. The UDP Nostr receiver drops invalid packets silently (no response), which means a standard UDP health check will fail. Options:
- (a) Run a separate TCP health check endpoint on the backend (e.g., a simple TCP listener on port 8888 that accepts connections). The accelerator checks TCP 8888 for health and routes UDP 8889 to the same backend.
- (b) Add a minimal UDP health check response to the relay: if the accelerator sends a specific probe byte, respond with a single byte. This is a tiny addition to
udp_nostr_recv.pyand does not compromise the no-handshake property (the probe is not a Nostr event, and the response is a single byte, not a protocol handshake). - (c) Use an NLB as the backend target, and let the NLB's health check handle it.
Recommended: option (b) — add a 3-line UDP health check responder to the receiver. The accelerator sends a probe; the receiver responds with a single byte. This is not a handshake (it doesn't reveal that the server is a Nostr relay — any UDP server could respond to a probe), and it keeps the setup simple.
Deliverable: running Global Accelerator with a UDP listener routing to the relay backend. Document the anycast IPs.
Step 3 — Verify end-to-end through the accelerator
# Get the accelerator's anycast IPs from AWS console
ANYCAST_IP=<accelerator_anycast_ip>
# Send a test event
echo '<signed_event_json>' | python3 src/udp_nostr_send.py $ANYCAST_IP 8889
# Verify it arrived at the backend
# (check the receiver's output)
Also verify from a different geographic location (the anycast IP routes to the nearest edge, so testing from one location only exercises one edge).
Deliverable: src/test_accelerator_relay.sh — sends a test event to the accelerator IP and verifies delivery.
Step 4 — Add mobility via FIPS discovery
Implement the relay-side advert publisher from plans/udp_nostr_fips_discovery.md Step 1 (udp_nostr_relay_advertise.sh):
- Publishes a kind 37195 advert with the accelerator's anycast IP and port as the UDP endpoint.
- Re-publishes on a timer (every 30 min) to keep the advert fresh.
- Uses a distinct
dtag (e.g.udp-nostr-v1) so FIPS nodes don't try to FMP-handshake it.
The key insight: the advert points at the accelerator's anycast IP, not the backend's IP. The backend can change without changing the advert. The advert only changes if the accelerator itself is recreated (e.g. AWS account terminated and relay moves to a new accelerator).
Implement the sender-side resolver and one-shot sender from the FIPS plan Steps 3–4.
Deliverable: the files listed in plans/udp_nostr_fips_discovery.md file summary.
Step 5 — Backend mobility: swap backends without changing the client IP
Document and test the procedure for moving the backend:
- Start a new relay backend (new EC2 instance, new region, or new host).
- In the Global Accelerator endpoint group, add the new backend as a new endpoint.
- Wait for the new endpoint to pass health checks.
- Remove the old endpoint from the endpoint group.
- Traffic now routes to the new backend. The anycast IP is unchanged. The advert is unchanged. Senders see no disruption.
This is the zero-downtime mobility path: the relay moves without any sender-visible change. This is the main advantage of the cloud-LB approach over bare-IP hosting.
Deliverable: docs/backend_mobility.md — step-by-step procedure for swapping backends.
Step 6 — Full mobility: moving to a new accelerator
If the AWS account is compromised or the accelerator's anycast IP is specifically blocked, the relay must move to a new accelerator (or a new cloud provider). This is the full mobility path:
- Create a new Global Accelerator (or GCP Network LB, or Azure LB) in a new account/provider.
- Start the relay backend behind the new accelerator.
- Re-publish the kind 37195 advert with the new accelerator's anycast IP.
- Senders pick up the new endpoint on their next resolve (within the advert expiration window).
This path has re-advertise latency (senders don't find the new endpoint until the advert propagates). Mitigation: short advert expiration (15–30 min), frequent re-publish, sender-side caching.
Deliverable: src/test_full_mobility.sh — simulate a full move: stop old accelerator, start new one, re-advertise, verify sender follows.
Step 7 — Document the combination
Add a section to README.md (or a new docs/cloud_lb_collateral.md) explaining:
- The principle: cloud-LB IPs provide range-level collateral (not per-IP like Cloudflare), plus anycast hardening and backend mobility.
- The honest distinction between per-IP collateral (Cloudflare) and range-level collateral (AWS/GCP/Azure).
- The AWS Global Accelerator setup for UDP Nostr.
- How this composes with FIPS discovery for mobility.
- The tradeoffs (cost, AWS account KYC, no per-IP collateral, health check requirement).
- The residual risks (targeted IP blocking, AWS account termination, range-blocking by determined regimes).
Deliverable: new documentation section.
Open questions / decisions
-
AWS Global Accelerator vs. NLB. Global Accelerator provides anycast + backend mobility but costs more (~$18/month fixed + data premiums). NLB is cheaper but unicast (no anycast) and per-region. Which is the right default? Recommendation: Global Accelerator for production (anycast + mobility are worth the cost); NLB for testing/development.
-
Health check approach. The relay drops invalid packets silently, so standard UDP health checks fail. Options: (a) separate TCP health check port, (b) minimal UDP probe response added to the receiver, (c) NLB as intermediary. Option (b) is recommended — does it compromise the no-handshake property? Arguably not (a single-byte probe response is not a protocol handshake), but it's worth a deliberate decision.
-
UDP port 443 vs. 8889. Port 443 blends with QUIC/HTTP3 traffic (more cover, more collateral). Port 8889 is distinctive but avoids conflicts. Global Accelerator supports any port. Recommendation: 443 if blending with QUIC is desired; 8889 if simplicity is preferred. See
docs/udp_nostr_relay_plan.mdfor the port-443 rationale. -
Multi-cloud for resilience. Should the relay maintain accelerators on multiple cloud providers (AWS + GCP) simultaneously, advertising both endpoints in the kind 37195 advert? This would mean blocking the relay requires blocking IPs in multiple cloud providers' ranges. Higher cost, higher resilience. Worth planning as a follow-up?
-
AWS account KYC risk. AWS knows the account holder's identity. If the relay is targeted by a regime that can pressure AWS, the account could be terminated. Mitigation: the backend can be outside AWS; only the accelerator is on AWS. But the accelerator is tied to an account. Is this an acceptable risk, or should we explore anonymous cloud accounts / crypto-paid cloud providers?
-
Does the accelerator's anycast IP actually provide enough collateral? The IP is dedicated to your accelerator, not shared with other customers. The collateral is range-level (blocking AWS's IP space). Is range-level collateral sufficient for the threat model, or do we need per-IP sharing (which would require Cloudflare Spectrum or similar — a different plan)?
What this does NOT do
- It does not provide per-IP collateral like Cloudflare. The anycast IP is dedicated to your accelerator. The collateral is at the cloud provider's IP-range level.
- It does not protect against targeted single-IP blocking. If the regime identifies your specific anycast IP and blocks just that IP, only your relay is affected. Mobility (moving to a new accelerator) is the response.
- It does not change the UDP Nostr protocol. The datagram is still a single signed event, no handshake, no response (except the optional health-check probe).
- It does not hide the relay's IP from the adversary. The anycast IP is public. The backend's IP is hidden from the sender (traffic enters at the AWS edge), but AWS and the regime can see the backend IP in AWS's internal routing.
- It does not provide anonymity for the sender. The sender's IP is visible to the accelerator (and thus to AWS). Use Superball (
plans/udp_nostr_superball.md) for sender location privacy. - It does not solve the sender's discovery censorship problem. The sender's lookup of the kind 37195 advert is a WSS handshake. Mitigations are in
plans/udp_nostr_fips_discovery.md.
File summary
| File | Status | Purpose |
|---|---|---|
| AWS Global Accelerator (UDP listener) | new (AWS) | Anycast UDP front-end, range-level collateral IP |
src/udp_nostr_recv.py |
existing (minor mod) | Relay: add optional UDP health-check probe response (3 lines) |
src/test_accelerator_relay.sh |
new | Send test event through accelerator, verify delivery |
docs/backend_mobility.md |
new | Step-by-step: swap backends without changing client IP |
src/test_full_mobility.sh |
new | Simulate full move: new accelerator, re-advertise, verify |
docs/cloud_lb_collateral.md (or README section) |
new | Document the cloud-LB collateral + mobility combination |
src/udp_nostr_relay_advertise.sh |
from FIPS plan | Relay: publish kind 37195 endpoint advert (anycast IP) |
src/udp_nostr_resolve.py |
from FIPS plan | Sender: fetch relay's current UDP endpoint by npub |
src/udp_nostr_send_via_npub.sh |
from FIPS plan | Sender: one-shot resolve + send |
src/udp_nostr_send.py |
existing | Sender: unchanged sender |
Relationship to other plans
| Layer | Plan | Role |
|---|---|---|
| IP hardening — cloud-LB collateral | this plan (AWS Global Accelerator) | Range-level collateral + anycast + backend mobility |
| IP hardening — discovery mobility | plans/udp_nostr_fips_discovery.md |
npub → current endpoint; relay can move accelerators |
| Data plane — no handshake | UDP Nostr core (docs/no_handshake.md) |
DPI fingerprinting and active probing fail |
| Sender location privacy | plans/udp_nostr_superball.md |
Hide sender's IP via multi-hop mixing |
| Final delivery | Nostr relays (existing) | Public, durable, queryable |
The full stack: a sender looks up the relay by npub (FIPS discovery), sends a no-handshake UDP datagram to the relay's AWS Global Accelerator anycast IP (this plan + core), optionally via Superball for sender privacy, and the event lands on public Nostr relays for the world to read.