diff --git a/.env.example b/.env.example index 8f3d0bd..e6d962b 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,16 @@ # Default: 127.0.0.1:7334 # NGIT_BIND_ADDRESS=127.0.0.1:7334 +# Trusted reverse-proxy address ranges (comma-separated IPv4/IPv6 CIDRs) +# Forwarded, X-Forwarded-For, and X-Real-IP are ignored unless the TCP peer +# matches one of these ranges. Keep empty for direct/public listeners. +# When trusting a proxy, keep the ngit-grasp backend unreachable from +# untrusted networks and include every trusted hop in a forwarding chain. +# CLI: --trusted-proxy-cidrs +# Default: (empty) +# Example for a reverse proxy on the same host: +# NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 + # ============================================================================ # RELAY INFORMATION (NIP-11) # ============================================================================ diff --git a/CHANGELOG.md b/CHANGELOG.md index dd647f9..7e0a51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking changes + +- Added `trusted_proxy_cidrs` to the public `Config` struct. Rust consumers + that construct `Config` with a struct literal must provide it. + +### Security + +- Added opt-in trusted-proxy CIDRs so WebSocket connection policy, per-IP + metrics, abuse indicators, and logs can use the real client address without + trusting spoofable forwarding headers from arbitrary direct peers. + ### Added - Add bounded, operator-configurable inbox fallback coverage when a successful diff --git a/Cargo.lock b/Cargo.lock index a89adef..872c1f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,6 +1163,9 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1333,6 +1336,7 @@ dependencies = [ "hyper", "hyper-util", "indexmap", + "ipnet", "lazy_static", "libc", "nostr", diff --git a/Cargo.toml b/Cargo.toml index 6105a4d..c7d4ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ base64 = "0.22" flate2 = "1.0" tar = "0.4" fs2 = "0.4" +ipnet = { version = "2", features = ["serde"] } libc = "0.2" # Metrics diff --git a/README.md b/README.md index ffc03b3..01a28c9 100644 --- a/README.md +++ b/README.md @@ -450,16 +450,17 @@ passes the key through a protected systemd credential. #### Core Settings -| Option | CLI Flag | Environment Variable | Default | -| ----------------- | --------------------- | ------------------------ | -------------------------------------------- | -| Domain | `--domain` | `NGIT_DOMAIN` | (required) | -| Relay owner nsec | — | `NGIT_RELAY_OWNER_NSEC` | systemd credential, then `.relay-owner.nsec` | -| Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` | -| Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` | -| Git data path | `--git-data-path` | `NGIT_GIT_DATA_PATH` | `./data/git` (temp dir for memory backend) | -| Relay data path | `--relay-data-path` | `NGIT_RELAY_DATA_PATH` | `./data/relay` (temp dir for memory backend) | -| Bind address | `--bind-address` | `NGIT_BIND_ADDRESS` | `127.0.0.1:7334` (NGIT on phone keypad) | -| Database backend | `--database-backend` | `NGIT_DATABASE_BACKEND` | `lmdb` | +| Option | CLI Flag | Environment Variable | Default | +| ------------------- | ------------------------ | --------------------------- | -------------------------------------------- | +| Domain | `--domain` | `NGIT_DOMAIN` | (required) | +| Relay owner nsec | — | `NGIT_RELAY_OWNER_NSEC` | systemd credential, then `.relay-owner.nsec` | +| Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` | +| Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` | +| Git data path | `--git-data-path` | `NGIT_GIT_DATA_PATH` | `./data/git` (temp dir for memory backend) | +| Relay data path | `--relay-data-path` | `NGIT_RELAY_DATA_PATH` | `./data/relay` (temp dir for memory backend) | +| Bind address | `--bind-address` | `NGIT_BIND_ADDRESS` | `127.0.0.1:7334` (NGIT on phone keypad) | +| Trusted proxy CIDRs | `--trusted-proxy-cidrs` | `NGIT_TRUSTED_PROXY_CIDRS` | (empty; forwarded headers ignored) | +| Database backend | `--database-backend` | `NGIT_DATABASE_BACKEND` | `lmdb` | #### GRASP-02 Sync Settings @@ -515,6 +516,10 @@ export NGIT_RELAY_OWNER_NSEC=nsec1... # Or let it auto-generate from .relay-own export NGIT_BIND_ADDRESS=0.0.0.0:7334 export NGIT_DATABASE_BACKEND=lmdb +# When a private backend is reached only through a reverse proxy: +# export NGIT_BIND_ADDRESS=127.0.0.1:7334 +# export NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32 + # Optional: Enable proactive sync from a bootstrap relay export NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.damus.io diff --git a/docs/how-to/deploy.md b/docs/how-to/deploy.md index 111bb29..09931ec 100644 --- a/docs/how-to/deploy.md +++ b/docs/how-to/deploy.md @@ -72,6 +72,8 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): # Network bindAddress = "127.0.0.1"; port = 8082; + # Only Caddy can reach the loopback backend, so its forwarded client IP is trusted. + trustedProxyCidrs = [ "127.0.0.1/32" ]; # Storage dataDir = "/persistent/ngit-grasp"; @@ -95,8 +97,8 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): services.caddy.virtualHosts."ngit.example.com" = { extraConfig = '' reverse_proxy 127.0.0.1:8082 { - header_down X-Real-IP {http.request.remote} - header_down X-Forwarded-For {http.request.remote} + # Caddy manages X-Forwarded-For automatically. + header_up X-Real-IP {remote_host} } ''; }; @@ -108,6 +110,11 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): - **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`) - **domain**: Your relay's domain (used in GRASP validation) - **port**: Local port (use reverse proxy for HTTPS) +- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP + - Keep empty for a directly exposed listener + - Keep the backend private; trusting a public-facing source range permits spoofed headers + - Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`, + changes headers sent to the backend - **dataDir**: Where git repos and database are stored - **relayOwnerNsecFile**: Path to file containing relay owner's nsec - Passed to ngit-grasp as a protected systemd credential, not a process argument @@ -268,6 +275,8 @@ git ls-remote https://ngit.example.com//.git ### Network - `bindAddress` - IP to bind to (default: "127.0.0.1") - `port` - Port to listen on (default: 7334) +- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client + IP (default: empty; forwarded headers ignored) ### Storage - `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name}) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0e0a298..938fce2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -47,11 +47,60 @@ NGIT_BIND_ADDRESS=127.0.0.1:3000 **Notes:** - Use `127.0.0.1` for local development -- Use `0.0.0.0` for production (behind reverse proxy) +- Prefer a loopback or private address behind a production reverse proxy +- Use `0.0.0.0` only when the listener must be directly reachable - Ensure firewall rules allow the port --- +#### `NGIT_TRUSTED_PROXY_CIDRS` + +**Description:** Comma-separated IPv4 or IPv6 networks whose forwarding +headers may identify a WebSocket client + +**Type:** CIDR list + +**Default:** Empty + +**Required:** No + +**Examples:** + +```bash +# Caddy or nginx connects over IPv4 loopback +NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32 + +# Reverse proxy may connect over either loopback family +NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 + +# Two private proxy tiers +NGIT_TRUSTED_PROXY_CIDRS=10.10.0.0/24,10.20.0.0/24 +``` + +When the TCP peer is in one of these networks, ngit-grasp resolves the client +from `X-Forwarded-For`, `Forwarded`, or `X-Real-IP`, in that order. Forwarding +chains are walked from the TCP peer inward and stop at the first untrusted hop. +This resolved IP is used for relay connection policy, per-IP connection +metrics, abuse indicators, and WebSocket connection logs. + +Forwarding headers are always ignored for untrusted TCP peers. Malformed +headers also fall back to the peer address rather than a less authoritative +header. Invalid CIDRs stop startup. + +**Security requirements:** + +- Leave this empty for a directly exposed ngit-grasp listener. +- Configure the reverse proxy to append or overwrite forwarding headers rather + than passing client-controlled values unchanged. +- Bind ngit-grasp to loopback/private interfaces or firewall the backend so + untrusted clients cannot connect from an address included in this list. +- Include every proxy hop that should be traversed. The first address outside + the trusted ranges is treated as the client. + +The corresponding NixOS option is `trustedProxyCidrs`. + +--- + #### `NGIT_DOMAIN` **Description:** Public domain name for this GRASP instance @@ -1568,13 +1617,16 @@ NGIT_RELAY_NAME="GitNostr Public Relay" NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects" NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay -NGIT_BIND_ADDRESS=0.0.0.0:7334 +NGIT_BIND_ADDRESS=127.0.0.1:7334 +NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32 RUST_LOG=info,ngit_grasp=debug ``` **Additional production considerations:** - Use reverse proxy (nginx, Caddy) for HTTPS +- Keep the backend private and set `NGIT_TRUSTED_PROXY_CIDRS` to the actual + proxy source networks if client-IP accounting is required - Set up log rotation - Configure monitoring - Implement backup strategy diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix index 4019c66..8905cf2 100644 --- a/nix/example-configuration.nix +++ b/nix/example-configuration.nix @@ -25,6 +25,9 @@ # Network bindAddress = "127.0.0.1"; port = 8082; + # Trust forwarding headers only from the loopback Caddy connection. + # Keep this empty if ngit-grasp is directly exposed. + trustedProxyCidrs = [ "127.0.0.1/32" ]; # Storage dataDir = "/persistent/ngit-danconwaydev-com-ngit-grasp"; @@ -58,8 +61,8 @@ services.caddy.virtualHosts."ngit.danconwaydev.com" = { extraConfig = '' reverse_proxy 127.0.0.1:8082 { - header_down X-Real-IP {http.request.remote} - header_down X-Forwarded-For {http.request.remote} + # Caddy manages X-Forwarded-For automatically. + header_up X-Real-IP {remote_host} } ''; }; diff --git a/nix/module.nix b/nix/module.nix index 0be9277..c3fb1d4 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -41,6 +41,19 @@ let description = "IP address to bind to"; }; + trustedProxyCidrs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "127.0.0.1/32" "::1/128" "10.0.0.0/8" ]; + description = '' + IP address ranges for reverse proxies whose Forwarded, + X-Forwarded-For, or X-Real-IP headers may identify WebSocket + clients. Leave empty unless ngit-grasp is behind a trusted proxy. + Keep the backend unreachable from untrusted networks and include + every trusted proxy hop needed to resolve a forwarding chain. + ''; + }; + port = mkOption { type = types.port; default = 7334; @@ -552,6 +565,8 @@ let concatStringsSep "," cfg.syncPlusFallbackRelays; } // optionalAttrs (cfg.maxConnections != null) { NGIT_MAX_CONNECTIONS = toString cfg.maxConnections; + } // optionalAttrs (cfg.trustedProxyCidrs != [ ]) { + NGIT_TRUSTED_PROXY_CIDRS = concatStringsSep "," cfg.trustedProxyCidrs; } // optionalAttrs (cfg.relayName != null) { NGIT_RELAY_NAME = cfg.relayName; } // optionalAttrs (cfg.archiveReadOnly != null) { diff --git a/src/config.rs b/src/config.rs index 71a2800..eb94d89 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, ValueEnum}; +use ipnet::IpNet; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use std::fs; @@ -360,6 +361,12 @@ pub struct Config { #[arg(long, env = "NGIT_BIND_ADDRESS", default_value = "127.0.0.1:7334")] pub bind_address: String, + /// Proxy address ranges allowed to supply client IP forwarding headers. + /// + /// Empty by default so forwarded headers from direct clients are ignored. + #[arg(long, env = "NGIT_TRUSTED_PROXY_CIDRS", value_delimiter = ',')] + pub trusted_proxy_cidrs: Vec, + /// Database backend type #[arg(long, env = "NGIT_DATABASE_BACKEND", value_enum, default_value_t = DatabaseBackend::Lmdb)] pub database_backend: DatabaseBackend, @@ -1125,6 +1132,7 @@ impl Config { git_data_path: "./test_data/git".to_string(), relay_data_path: "./test_data/relay".to_string(), bind_address: "127.0.0.1:7334".to_string(), + trusted_proxy_cidrs: Vec::new(), database_backend: DatabaseBackend::Memory, metrics_enabled: true, metrics_connection_per_ip_abuse_threshold: 10, @@ -1213,6 +1221,7 @@ fn secure_secret_file_permissions(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::net::IpAddr; use std::sync::Mutex; static CONFIG_ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -1222,10 +1231,60 @@ mod tests { let config = Config::for_testing(); assert_eq!(config.domain, "localhost:7334"); assert_eq!(config.bind_address, "127.0.0.1:7334"); + assert!(config.trusted_proxy_cidrs.is_empty()); // for_testing() uses Memory, but the actual default is Lmdb assert_eq!(config.database_backend, DatabaseBackend::Memory); } + #[test] + fn test_trusted_proxy_cidrs_parse_from_cli() { + let config = Config::try_parse_from([ + "ngit-grasp", + "--domain", + "example.com", + "--trusted-proxy-cidrs", + "127.0.0.1/32,10.0.0.0/8,2001:db8::/32", + ]) + .expect("trusted proxy CIDRs should parse"); + + assert_eq!(config.trusted_proxy_cidrs.len(), 3); + assert!(config.trusted_proxy_cidrs[0].contains(&"127.0.0.1".parse::().unwrap())); + assert!(config.trusted_proxy_cidrs[1].contains(&"10.2.3.4".parse::().unwrap())); + assert!(config.trusted_proxy_cidrs[2].contains(&"2001:db8::1".parse::().unwrap())); + } + + #[test] + fn test_trusted_proxy_cidrs_reject_invalid_networks() { + let error = Config::try_parse_from([ + "ngit-grasp", + "--domain", + "example.com", + "--trusted-proxy-cidrs", + "127.0.0.1/32,not-a-network", + ]) + .expect_err("invalid trusted proxy CIDRs must stop startup"); + + assert!(error.to_string().contains("not-a-network")); + } + + #[test] + fn test_trusted_proxy_cidrs_parse_from_environment() { + const VARIABLE: &str = "NGIT_TRUSTED_PROXY_CIDRS"; + let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned"); + let original = std::env::var_os(VARIABLE); + std::env::set_var(VARIABLE, "127.0.0.1/32,::1/128"); + + let result = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"]); + + match original { + Some(value) => std::env::set_var(VARIABLE, value), + None => std::env::remove_var(VARIABLE), + } + + let config = result.expect("trusted proxy CIDRs should parse from the environment"); + assert_eq!(config.trusted_proxy_cidrs.len(), 2); + } + #[test] fn relay_hardening_defaults_are_explicit() { let config = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"]) diff --git a/src/http/client_ip.rs b/src/http/client_ip.rs new file mode 100644 index 0000000..d190508 --- /dev/null +++ b/src/http/client_ip.rs @@ -0,0 +1,258 @@ +use std::net::{IpAddr, SocketAddr}; + +use hyper::header::{HeaderMap, FORWARDED}; +use ipnet::IpNet; + +const X_FORWARDED_FOR: &str = "x-forwarded-for"; +const X_REAL_IP: &str = "x-real-ip"; + +/// Resolve the address used for WebSocket accounting and relay connection +/// policy without allowing arbitrary direct peers to spoof forwarding headers. +pub(super) fn resolve_client_addr( + peer: SocketAddr, + headers: &HeaderMap, + trusted_proxies: &[IpNet], +) -> SocketAddr { + if !is_trusted(peer.ip(), trusted_proxies) { + return peer; + } + + // Prefer X-Forwarded-For because common reverse proxies (including Caddy) + // append the TCP source to that chain while an inbound RFC Forwarded + // header may otherwise pass through untouched. + let client_ip = if headers.contains_key(X_FORWARDED_FOR) { + parse_x_forwarded_for(headers) + .map(|chain| resolve_chain(peer.ip(), &chain, trusted_proxies)) + } else if headers.contains_key(FORWARDED) { + parse_forwarded(headers).map(|chain| resolve_chain(peer.ip(), &chain, trusted_proxies)) + } else if headers.contains_key(X_REAL_IP) { + parse_x_real_ip(headers) + } else { + None + }; + + client_ip + .map(|ip| SocketAddr::new(ip, peer.port())) + .unwrap_or(peer) +} + +/// Walk from the TCP peer towards the originating client. Each forwarding hop +/// is consumed only while the current hop is trusted, so client-supplied +/// entries to the left of the first untrusted address cannot take effect. +fn resolve_chain(peer: IpAddr, forwarded: &[IpAddr], trusted_proxies: &[IpNet]) -> IpAddr { + let mut current = peer; + + for forwarded_ip in forwarded.iter().rev() { + if !is_trusted(current, trusted_proxies) { + break; + } + current = *forwarded_ip; + } + + current +} + +fn is_trusted(ip: IpAddr, trusted_proxies: &[IpNet]) -> bool { + trusted_proxies.iter().any(|network| network.contains(&ip)) +} + +fn parse_forwarded(headers: &HeaderMap) -> Option> { + let mut addresses = Vec::new(); + + for value in headers.get_all(FORWARDED) { + let value = value.to_str().ok()?; + for element in value.split(',') { + let mut address = None; + for parameter in element.split(';') { + let (name, value) = parameter.trim().split_once('=')?; + if name.trim().eq_ignore_ascii_case("for") { + if address.is_some() { + return None; + } + address = parse_forwarded_address(value.trim()); + address?; + } + } + addresses.push(address?); + } + } + + (!addresses.is_empty()).then_some(addresses) +} + +fn parse_forwarded_address(value: &str) -> Option { + let value = match (value.strip_prefix('"'), value.strip_suffix('"')) { + (Some(_), None) | (None, Some(_)) => return None, + (Some(without_prefix), Some(_)) => without_prefix.strip_suffix('"')?, + (None, None) => value, + }; + + if value.is_empty() || value.eq_ignore_ascii_case("unknown") || value.starts_with('_') { + return None; + } + + value + .parse::() + .ok() + .or_else(|| value.parse::().ok().map(|addr| addr.ip())) + .or_else(|| { + value + .strip_prefix('[')? + .strip_suffix(']')? + .parse::() + .ok() + }) +} + +fn parse_x_forwarded_for(headers: &HeaderMap) -> Option> { + let mut addresses = Vec::new(); + + for value in headers.get_all(X_FORWARDED_FOR) { + let value = value.to_str().ok()?; + for address in value.split(',') { + addresses.push(address.trim().parse().ok()?); + } + } + + (!addresses.is_empty()).then_some(addresses) +} + +fn parse_x_real_ip(headers: &HeaderMap) -> Option { + let mut values = headers.get_all(X_REAL_IP).iter(); + let address = values.next()?.to_str().ok()?.trim().parse().ok()?; + values.next().is_none().then_some(address) +} + +#[cfg(test)] +mod tests { + use super::*; + use hyper::header::{HeaderValue, FORWARDED}; + + fn address(value: &str) -> SocketAddr { + value.parse().unwrap() + } + + fn networks(values: &[&str]) -> Vec { + values.iter().map(|value| value.parse().unwrap()).collect() + } + + #[test] + fn ignores_spoofed_headers_from_an_untrusted_peer() { + let peer = address("198.51.100.20:4242"); + let mut headers = HeaderMap::new(); + headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("203.0.113.9")); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])), + peer + ); + } + + #[test] + fn uses_the_tcp_peer_without_forwarding_headers() { + let peer = address("127.0.0.1:4242"); + + assert_eq!( + resolve_client_addr(peer, &HeaderMap::new(), &networks(&["127.0.0.1/32"])), + peer + ); + } + + #[test] + fn resolves_a_client_behind_one_trusted_proxy() { + let peer = address("127.0.0.1:4242"); + let mut headers = HeaderMap::new(); + headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.20")); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(), + "198.51.100.20".parse::().unwrap() + ); + } + + #[test] + fn walks_a_chain_of_trusted_proxies_from_the_peer_inward() { + let peer = address("10.0.0.3:4242"); + let mut headers = HeaderMap::new(); + headers.insert( + X_FORWARDED_FOR, + HeaderValue::from_static("198.51.100.20, 10.0.0.2"), + ); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["10.0.0.0/24"])).ip(), + "198.51.100.20".parse::().unwrap() + ); + } + + #[test] + fn stops_before_client_supplied_entries_left_of_the_first_untrusted_hop() { + let peer = address("10.0.0.3:4242"); + let mut headers = HeaderMap::new(); + headers.insert( + X_FORWARDED_FOR, + HeaderValue::from_static("203.0.113.9, 198.51.100.20"), + ); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["10.0.0.0/24"])).ip(), + "198.51.100.20".parse::().unwrap() + ); + } + + #[test] + fn fails_closed_on_a_malformed_forwarding_chain() { + let peer = address("127.0.0.1:4242"); + let mut headers = HeaderMap::new(); + headers.insert( + X_FORWARDED_FOR, + HeaderValue::from_static("198.51.100.20, invalid"), + ); + headers.insert(X_REAL_IP, HeaderValue::from_static("198.51.100.20")); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])), + peer + ); + } + + #[test] + fn parses_forwarded_ipv6_addresses_and_ports() { + let peer = address("127.0.0.1:4242"); + let mut headers = HeaderMap::new(); + headers.insert( + FORWARDED, + HeaderValue::from_static("for=\"[2001:db8::7]:4711\";proto=https"), + ); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(), + "2001:db8::7".parse::().unwrap() + ); + } + + #[test] + fn prefers_a_proxy_appended_x_forwarded_for_chain() { + let peer = address("127.0.0.1:4242"); + let mut headers = HeaderMap::new(); + headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.20")); + headers.insert(FORWARDED, HeaderValue::from_static("for=203.0.113.9")); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(), + "198.51.100.20".parse::().unwrap() + ); + } + + #[test] + fn uses_x_real_ip_when_no_chain_header_is_present() { + let peer = address("127.0.0.1:4242"); + let mut headers = HeaderMap::new(); + headers.insert(X_REAL_IP, HeaderValue::from_static("198.51.100.20")); + + assert_eq!( + resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(), + "198.51.100.20".parse::().unwrap() + ); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index a91ef71..d0ba415 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,6 +1,7 @@ -/// HTTP Server Module -/// -/// Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay. +//! HTTP Server Module +//! +//! Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay. +mod client_ip; pub mod landing; pub mod nip11; @@ -722,14 +723,23 @@ impl Service> for HttpService { let key = req.headers().get("sec-websocket-key"); let derived = key.map(|k| derive_accept_key(k.as_bytes())); - let addr = self.remote; + let peer = self.remote; + let addr = client_ip::resolve_client_addr( + peer, + req.headers(), + &self.config.trusted_proxy_cidrs, + ); let relay = self.relay.clone(); let metrics_clone = self.metrics.clone(); tokio::spawn(async move { match hyper::upgrade::on(req).await { Ok(upgraded) => { - tracing::info!("WebSocket connection established from {}", addr); + tracing::info!( + client_ip = %addr.ip(), + peer = %peer, + "WebSocket connection established" + ); // Track connection let _connection_timer = metrics_clone.as_ref().map(|m| m.start_connection_timer()); @@ -740,9 +750,18 @@ impl Service> for HttpService { if let Err(e) = relay.take_connection(TokioIo::new(upgraded), addr).await { - tracing::error!("Relay error for {}: {}", addr, e); + tracing::error!( + client_ip = %addr.ip(), + peer = %peer, + error = %e, + "Relay connection failed" + ); } - tracing::info!("WebSocket connection closed for {}", addr); + tracing::info!( + client_ip = %addr.ip(), + peer = %peer, + "WebSocket connection closed" + ); // Untrack connection if let Some(ref m) = metrics_clone { m.connection_tracker().on_disconnect(addr.ip()); @@ -907,6 +926,12 @@ pub async fn run_server_on_listener( tracing::info!("Starting HTTP server on {}", listener.local_addr()?); tracing::info!("Relay name: {}", config.relay_name()); tracing::info!("Domain: {}", config.domain); + if !config.trusted_proxy_cidrs.is_empty() { + tracing::info!( + trusted_proxy_cidrs = ?config.trusted_proxy_cidrs, + "Trusted proxy client IP resolution enabled" + ); + } loop { let (socket, addr) = listener.accept().await?;