websocket: add ws_defaultmode (text/binary/reject), ws_proxyranges (glob/CIDR X-Real-IP/XFF trust), ws_allowmissingorigin, ws_nativeping
This commit is contained in:
parent
87a683dbd2
commit
4f6c0ded48
4 changed files with 116 additions and 37 deletions
|
|
@ -137,11 +137,24 @@ amu_target = both
|
|||
# syslog_tag = echoircd
|
||||
|
||||
# --- PROXY protocol: trust the HAProxy/nginx PROXY header (v1 or v2) from these
|
||||
# source IPs (glob, repeatable), so the real client IP is used instead of the
|
||||
# proxy's. A connection from a trusted proxy MUST lead with a PROXY header.
|
||||
# sources (glob or CIDR, repeatable), so the real client IP is used instead of
|
||||
# the proxy's. A connection from a trusted proxy MUST lead with a PROXY header.
|
||||
# Applies to the plaintext and TLS client listeners (WebSocket uses XFF instead).
|
||||
# proxy = 127.0.0.1
|
||||
# proxy = 10.0.0.*
|
||||
# proxy = 10.0.0.0/8
|
||||
|
||||
# --- WebSocket transport (browser IRC clients connect straight to echoIRCd) ---
|
||||
# bind_ws = 127.0.0.1:8097 # ws:// listener
|
||||
# bind_wss = 0.0.0.0:7799 # wss:// listener (uses tls_cert/tls_key)
|
||||
# ws_origin = https://x.example # (repeatable) allowed Origin globs; empty = any
|
||||
# ws_defaultmode = text # frame mode with no subprotocol: text|binary|reject
|
||||
# ws_proxyranges = 127.0.0.1 # (repeatable) glob/CIDR of proxies whose
|
||||
# # X-Real-IP / X-Forwarded-For we trust
|
||||
# ws_allowmissingorigin = yes # allow clients that send no Origin header
|
||||
# ws_nativeping = yes # liveness via WebSocket pings (no = IRC PING)
|
||||
# ws_handshake_timeout = 10 # seconds to complete the HTTP Upgrade
|
||||
# ws_ping_interval = 60 # seconds between WebSocket keepalive pings
|
||||
# ws_timeout = 120 # drop after this many seconds of silence
|
||||
|
||||
# --- security groups: securitygroup = <name> [criteria...]
|
||||
# criteria: public tls insecure account unregistered oper exclude-oper
|
||||
|
|
|
|||
|
|
@ -196,6 +196,13 @@ fn cidr_contains(base: IpAddr, bits: u8, target: IpAddr) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `ip` matches `mask`, where `mask` is a CIDR range or an IP glob. Shared
|
||||
/// with the WebSocket `proxyranges` and PROXY-protocol trust checks so they accept
|
||||
/// the same glob-or-CIDR syntax.
|
||||
pub fn ip_matches(mask: &str, ip: &str) -> bool {
|
||||
mask_match(mask, ip, "")
|
||||
}
|
||||
|
||||
/// Match one mask against a client's IP and (once known) resolved host. A mask with
|
||||
/// a `/` is a CIDR range tested against the IP; otherwise it's a glob tested against
|
||||
/// both the IP text and the host.
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ pub fn run_reactor(
|
|||
// add_conn sees the real client IP.
|
||||
let via_proxy = proxy_trust
|
||||
.iter()
|
||||
.any(|g| crate::channels::glob_match(g, &addr.ip().to_string()));
|
||||
.any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string()));
|
||||
let out = OutSink::Reactor {
|
||||
token,
|
||||
tx: out_tx.clone(),
|
||||
|
|
@ -687,7 +687,7 @@ fn tls_conn(
|
|||
// TLS handshake); consume it and rewrite the client address.
|
||||
let addr = if proxy_trust
|
||||
.iter()
|
||||
.any(|g| crate::channels::glob_match(g, &addr.ip().to_string()))
|
||||
.any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string()))
|
||||
{
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
||||
let real = match crate::proxy::read_header(&mut stream) {
|
||||
|
|
|
|||
103
src/websocket.rs
103
src/websocket.rs
|
|
@ -12,7 +12,12 @@
|
|||
//! ws_handshake_timeout = 10 seconds to finish the Upgrade
|
||||
//! ws_ping_interval = 60 seconds between server keepalive pings (0 = off)
|
||||
//! ws_timeout = 120 seconds with no traffic before we drop it
|
||||
//! ws_trust_proxy = no read X-Forwarded-For / -Proto (behind nginx)
|
||||
//! ws_defaultmode = text frame mode with no subprotocol: text|binary|reject
|
||||
//! ws_proxyranges = 127.0.0.1 (repeatable) glob/CIDR of proxies to trust
|
||||
//! X-Real-IP / X-Forwarded-For from
|
||||
//! ws_allowmissingorigin = yes allow clients that send no Origin header
|
||||
//! ws_nativeping = yes liveness via WebSocket pings (no ⇒ IRC PING)
|
||||
//! ws_trust_proxy = no legacy: trust proxy headers from any peer
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||
|
|
@ -46,6 +51,14 @@ const OP_CLOSE: u8 = 0x8;
|
|||
const OP_PING: u8 = 0x9;
|
||||
const OP_PONG: u8 = 0xA;
|
||||
|
||||
/// The frame mode used when a client negotiates no IRCv3 subprotocol.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum DefaultMode {
|
||||
Text,
|
||||
Binary,
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// Tunables read once from the config.
|
||||
#[derive(Clone)]
|
||||
pub struct WsConfig {
|
||||
|
|
@ -53,8 +66,11 @@ pub struct WsConfig {
|
|||
handshake_timeout: Duration,
|
||||
ping_interval: Duration,
|
||||
idle_timeout: Duration,
|
||||
trust_proxy: bool,
|
||||
binary_ok: bool,
|
||||
trust_proxy: bool, // legacy: trust proxy headers from any peer
|
||||
proxyranges: Vec<String>, // glob/CIDR of proxies whose headers we trust
|
||||
default_mode: DefaultMode, // frame mode when no subprotocol is negotiated
|
||||
allow_missing_origin: bool, // accept clients that send no Origin header
|
||||
native_ping: bool, // ping via WebSocket frames (else rely on IRC PING)
|
||||
}
|
||||
|
||||
/// A stream the WS session can drive — implemented for a plaintext `TcpStream`
|
||||
|
|
@ -123,6 +139,11 @@ pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
|
|||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(d))
|
||||
};
|
||||
let default_mode = match get("ws_defaultmode").map(|s| s.to_ascii_lowercase()).as_deref() {
|
||||
Some("binary") => DefaultMode::Binary,
|
||||
Some("reject") => DefaultMode::Reject,
|
||||
_ => DefaultMode::Text,
|
||||
};
|
||||
let wscfg = WsConfig {
|
||||
origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(),
|
||||
handshake_timeout: dur("ws_handshake_timeout", 10),
|
||||
|
|
@ -131,7 +152,12 @@ pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
|
|||
trust_proxy: get("ws_trust_proxy")
|
||||
.map(crate::config::yesish)
|
||||
.unwrap_or(false),
|
||||
binary_ok: true,
|
||||
proxyranges: cfg.raw.get("ws_proxyranges").cloned().unwrap_or_default(),
|
||||
default_mode,
|
||||
allow_missing_origin: get("ws_allowmissingorigin")
|
||||
.map(crate::config::yesish)
|
||||
.unwrap_or(true),
|
||||
native_ping: get("ws_nativeping").map(crate::config::yesish).unwrap_or(true),
|
||||
};
|
||||
|
||||
if let Some(bind) = get("bind_ws").map(str::to_string) {
|
||||
|
|
@ -228,7 +254,7 @@ fn ws_session<S: WsStream>(
|
|||
) {
|
||||
// --- HTTP Upgrade handshake (bounded by the handshake timeout) ---
|
||||
let _ = stream.set_read_timeout(Some(cfg.handshake_timeout));
|
||||
let hs = match do_handshake(&mut stream, &cfg) {
|
||||
let hs = match do_handshake(&mut stream, &cfg, addr.ip()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
let _ = shutdown.shutdown(Shutdown::Both);
|
||||
|
|
@ -353,7 +379,10 @@ fn io_loop<S: WsStream>(
|
|||
}
|
||||
let _ = stream.flush();
|
||||
|
||||
// 3) keepalive + idle timeout
|
||||
// 3) keepalive + idle timeout — WS-native pinging. With ws_nativeping=no the
|
||||
// IRC core's PING / ping-timeout drives liveness instead, so the WS layer
|
||||
// neither pings nor idle-drops.
|
||||
if cfg.native_ping {
|
||||
if cfg.ping_interval > Duration::ZERO && last_ping.elapsed() >= cfg.ping_interval {
|
||||
last_ping = Instant::now();
|
||||
if stream.write_all(&encode(OP_PING, b"echo")).is_err() {
|
||||
|
|
@ -364,6 +393,7 @@ fn io_loop<S: WsStream>(
|
|||
return; // dead connection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a completed data message into IRC lines and forward them; returns false if
|
||||
|
|
@ -472,8 +502,10 @@ fn encode(opcode: u8, payload: &[u8]) -> Vec<u8> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Read and validate the HTTP Upgrade request, then write the 101 response.
|
||||
fn do_handshake<S: WsStream>(stream: &mut S, cfg: &WsConfig) -> io::Result<Handshake> {
|
||||
/// Read and validate the HTTP Upgrade request, then write the 101 response. `peer`
|
||||
/// is the socket's remote IP, matched against `proxyranges` to decide whether the
|
||||
/// X-Real-IP / X-Forwarded-* headers may be trusted.
|
||||
fn do_handshake<S: WsStream>(stream: &mut S, cfg: &WsConfig, peer: IpAddr) -> io::Result<Handshake> {
|
||||
// read headers (bounded)
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 2048];
|
||||
|
|
@ -504,35 +536,62 @@ fn do_handshake<S: WsStream>(stream: &mut S, cfg: &WsConfig) -> io::Result<Hands
|
|||
}
|
||||
let key = hdr("sec-websocket-key").ok_or_else(|| io::Error::other("no key"))?;
|
||||
|
||||
// origin check (CSWSH guard): if any configured, the Origin must match one
|
||||
if !cfg.origins.is_empty() {
|
||||
let origin = hdr("origin").unwrap_or_default();
|
||||
if !cfg.origins.iter().any(|g| glob_match(g, &origin)) {
|
||||
// origin check (CSWSH guard): a present Origin must match a configured glob (if
|
||||
// any); a missing Origin is allowed unless ws_allowmissingorigin = no.
|
||||
match hdr("origin") {
|
||||
Some(origin) => {
|
||||
if !cfg.origins.is_empty() && !cfg.origins.iter().any(|g| glob_match(g, &origin)) {
|
||||
let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
||||
return Err(io::Error::other("origin rejected"));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !cfg.allow_missing_origin {
|
||||
let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
||||
return Err(io::Error::other("missing origin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subprotocol: prefer text.ircv3.net; accept binary.ircv3.net
|
||||
// subprotocol: prefer text.ircv3.net, accept binary.ircv3.net; with neither,
|
||||
// fall back to ws_defaultmode (and reject the handshake if that is "reject").
|
||||
let offered = hdr("sec-websocket-protocol")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let (chosen, binary) = if offered.split(',').any(|p| p.trim() == "text.ircv3.net") {
|
||||
(Some("text.ircv3.net"), false)
|
||||
} else if cfg.binary_ok && offered.split(',').any(|p| p.trim() == "binary.ircv3.net") {
|
||||
} else if offered.split(',').any(|p| p.trim() == "binary.ircv3.net") {
|
||||
(Some("binary.ircv3.net"), true)
|
||||
} else {
|
||||
(None, false)
|
||||
match cfg.default_mode {
|
||||
DefaultMode::Text => (None, false),
|
||||
DefaultMode::Binary => (None, true),
|
||||
DefaultMode::Reject => {
|
||||
let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
||||
return Err(io::Error::other("no subprotocol (reject mode)"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// real IP / scheme from a trusted reverse proxy
|
||||
// real IP / scheme from a trusted reverse proxy: trust the headers only when the
|
||||
// peer matches a configured proxyrange (glob/CIDR), or the legacy trust_proxy is on
|
||||
let trusted = if cfg.proxyranges.is_empty() {
|
||||
cfg.trust_proxy
|
||||
} else {
|
||||
let ip = peer.to_string();
|
||||
cfg.proxyranges
|
||||
.iter()
|
||||
.any(|r| crate::modules::connclass::ip_matches(r, &ip))
|
||||
};
|
||||
let (mut real_ip, mut secure) = (None, false);
|
||||
if cfg.trust_proxy {
|
||||
if let Some(xff) = hdr("x-forwarded-for") {
|
||||
if let Some(ip) = xff.split(',').next().and_then(|s| s.trim().parse().ok()) {
|
||||
real_ip = Some(ip);
|
||||
}
|
||||
}
|
||||
if trusted {
|
||||
// X-Real-IP wins; else the first hop of X-Forwarded-For
|
||||
real_ip = hdr("x-real-ip")
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.or_else(|| {
|
||||
hdr("x-forwarded-for")
|
||||
.and_then(|xff| xff.split(',').next().and_then(|s| s.trim().parse().ok()))
|
||||
});
|
||||
secure = hdr("x-forwarded-proto").is_some_and(|v| v.eq_ignore_ascii_case("https"));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue