websocket: native RFC 6455 transport (ws:// + wss://) — handshake, framing, ping/pong keepalive, idle timeout, origin/proxy flags, via_websocket

This commit is contained in:
Jean Chevronnet 2026-08-09 18:45:12 +00:00
parent 97b0d8ad89
commit 19157e0722
7 changed files with 633 additions and 0 deletions

View file

@ -27,6 +27,7 @@ pub enum Event {
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
link: bool, // a server-to-server connection, not a client
outbound: bool, // (link) we dialed them
websocket: bool, // arrived over the WebSocket transport
},
Line {
uid: Uid,
@ -114,11 +115,17 @@ impl Ircd {
certfp,
link,
outbound,
websocket,
} => {
if link {
self.server.add_link(uid, addr, out, sock, outbound);
} else {
self.server.add_conn(uid, addr, out, sock, secure, certfp);
if websocket {
if let Some(u) = self.server.users.get_mut(&uid) {
u.flags.via_websocket = true;
}
}
}
}
Event::Line { uid, line } => {

View file

@ -32,4 +32,5 @@ pub mod socketengine;
pub mod tls;
pub mod users;
pub mod watch;
pub mod websocket;
pub mod xline;

View file

@ -104,6 +104,9 @@ fn main() {
// optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc)
echoircd::modules::rpc::maybe_start(&cfg, tx.clone());
// optional WebSocket transport for browser IRC clients (see crate::websocket)
echoircd::websocket::maybe_start(&cfg, tx.clone(), counter.clone());
// dial any autoconnect uplinks (after a short delay so the peer can boot)
for block in cfg.links.iter().filter(|b| b.autoconnect) {
let addr = format!("{}:{}", block.ip, block.port);

View file

@ -64,6 +64,7 @@ fn full(s: &Server, uid: Uid) -> String {
("channels", format!("[{}]", channels.join(","))),
("oper", u.flags.oper.to_string()),
("secure", u.secure.to_string()),
("websocket", u.flags.via_websocket.to_string()),
];
if let Some(acct) = &u.account {
fields.push(("account", qstr(acct)));

View file

@ -187,6 +187,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
certfp: None,
link: false,
outbound: false,
websocket: false,
})
.is_err()
{
@ -384,6 +385,7 @@ pub fn accept_loop(
certfp: None,
link,
outbound: false,
websocket: false,
})
.is_err()
{
@ -432,6 +434,7 @@ pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
certfp: None,
link: true,
outbound: true,
websocket: false,
})
.is_err()
{
@ -516,6 +519,7 @@ fn tls_conn(
certfp,
link,
outbound: false,
websocket: false,
})
.is_err()
{

View file

@ -34,6 +34,7 @@ pub struct UserFlags {
pub deny_uncommon: bool, // +c (only users sharing a channel may PM you)
pub nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change)
pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups)
pub via_websocket: bool, // connected over the WebSocket transport (ws://, wss://)
pub away: Option<String>, // AWAY message, if set
}

616
src/websocket.rs Normal file
View file

@ -0,0 +1,616 @@
//! WebSocket transport (RFC 6455) — lets browser IRC clients (KiwiIRC, gamja,
//! The Lounge, reverse's Orbit, …) connect straight to echoIRCd, no Node bridge.
//! It's a transport, not a pluggable module, so it lives beside `tls.rs`/`http.rs`
//! at the I/O edge: a thread-per-connection listener that does the HTTP Upgrade
//! handshake, then frames the IRC byte stream in and out of WebSocket frames. One
//! thread owns each socket (like the TLS path) so frames never interleave.
//!
//! Native only: SHA-1 + base64 for the accept key come from OpenSSL; the framing
//! is hand-rolled; no new crate, no `unsafe`.
//!
//! Config (flat keys):
//! bind_ws = 127.0.0.1:8097 plaintext 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_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)
use std::io::{self, Read, Write};
use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use crate::channels::glob_match;
use crate::config::Config;
use crate::ircd::Event;
use crate::socketengine::OutSink;
use crate::tls::{OpensslBackend, TlsBackend, TlsConn};
use crate::Uid;
/// The RFC 6455 handshake GUID appended to the client key.
const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// Largest single WebSocket frame payload we'll accept (flood guard).
const MAX_FRAME: usize = 128 * 1024;
/// Largest reassembled message before we drop the connection.
const MAX_MSG: usize = 256 * 1024;
/// How long a session blocks on a read before draining writes / doing keepalive.
const POLL: Duration = Duration::from_millis(100);
// opcodes
const OP_CONT: u8 = 0x0;
const OP_TEXT: u8 = 0x1;
const OP_BIN: u8 = 0x2;
const OP_CLOSE: u8 = 0x8;
const OP_PING: u8 = 0x9;
const OP_PONG: u8 = 0xA;
/// Tunables read once from the config.
#[derive(Clone)]
pub struct WsConfig {
origins: Vec<String>,
handshake_timeout: Duration,
ping_interval: Duration,
idle_timeout: Duration,
trust_proxy: bool,
binary_ok: bool,
}
/// A stream the WS session can drive — implemented for a plaintext `TcpStream`
/// (`ws://`) and a TLS connection (`wss://`), so one session loop serves both.
pub trait WsStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
fn flush(&mut self) -> io::Result<()>;
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>;
fn shutdown(&mut self);
}
impl WsStream for TcpStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
Read::read(self, buf)
}
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
Write::write_all(self, buf)
}
fn flush(&mut self) -> io::Result<()> {
Write::flush(self)
}
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()> {
TcpStream::set_read_timeout(self, dur)
}
fn shutdown(&mut self) {
let _ = TcpStream::shutdown(self, Shutdown::Both);
}
}
impl WsStream for Box<dyn TlsConn> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
(**self).read(buf)
}
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
(**self).write_all(buf)
}
fn flush(&mut self) -> io::Result<()> {
(**self).flush()
}
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()> {
(**self).set_read_timeout(dur)
}
fn shutdown(&mut self) {
(**self).shutdown()
}
}
/// `Sec-WebSocket-Accept` = base64(SHA1(key + GUID)).
pub fn accept_key(client_key: &str) -> String {
let digest = openssl::hash::hash(
openssl::hash::MessageDigest::sha1(),
format!("{client_key}{GUID}").as_bytes(),
)
.map(|d| d.to_vec())
.unwrap_or_default();
openssl::base64::encode_block(&digest)
}
/// Start the ws:// and/or wss:// listeners if configured. Called from `main`.
pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
let get = |k: &str| cfg.raw.get(k).and_then(|v| v.last()).map(|s| s.as_str());
let dur = |k: &str, d: u64| {
get(k)
.and_then(|v| v.parse().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(d))
};
let wscfg = WsConfig {
origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(),
handshake_timeout: dur("ws_handshake_timeout", 10),
ping_interval: dur("ws_ping_interval", 60),
idle_timeout: dur("ws_timeout", 120),
trust_proxy: get("ws_trust_proxy")
.map(crate::config::yesish)
.unwrap_or(false),
binary_ok: true,
};
if let Some(bind) = get("bind_ws").map(str::to_string) {
match TcpListener::bind(&bind) {
Ok(l) => {
eprintln!("echoircd WebSocket (ws) on {bind}");
let (c, n, w) = (core.clone(), counter.clone(), wscfg.clone());
thread::spawn(move || accept_ws(l, c, n, None, w));
}
Err(e) => eprintln!("echoircd: cannot bind ws {bind}: {e}"),
}
}
if let Some(bind) = get("bind_wss").map(str::to_string) {
match (get("tls_cert"), get("tls_key")) {
(Some(cert), Some(key)) => match OpensslBackend::new(cert, key) {
Ok(backend) => match TcpListener::bind(&bind) {
Ok(l) => {
eprintln!("echoircd WebSocket (wss) on {bind} (openssl)");
let backend: Arc<dyn TlsBackend> = Arc::new(backend);
let (c, n, w) = (core.clone(), counter.clone(), wscfg.clone());
thread::spawn(move || accept_ws(l, c, n, Some(backend), w));
}
Err(e) => eprintln!("echoircd: cannot bind wss {bind}: {e}"),
},
Err(e) => eprintln!("echoircd: wss disabled (cert/key error): {e}"),
},
_ => eprintln!("echoircd: bind_wss set but tls_cert/tls_key missing — wss OFF"),
}
}
}
/// Accept forever; one thread per connection.
fn accept_ws(
listener: TcpListener,
core: Sender<Event>,
counter: Arc<AtomicU64>,
tls: Option<Arc<dyn TlsBackend>>,
cfg: WsConfig,
) {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let Ok(addr) = stream.peer_addr() else {
continue;
};
let _ = stream.set_nodelay(true);
let uid = counter.fetch_add(1, Ordering::Relaxed);
let (core, tls, cfg) = (core.clone(), tls.clone(), cfg.clone());
thread::spawn(move || ws_conn(stream, uid, addr, core, tls, cfg));
}
}
/// Per-connection entry: wrap in TLS for wss, keep a raw handle for force-close,
/// then run the generic session.
fn ws_conn(
raw: TcpStream,
uid: Uid,
addr: SocketAddr,
core: Sender<Event>,
tls: Option<Arc<dyn TlsBackend>>,
cfg: WsConfig,
) {
let Ok(shutdown) = raw.try_clone() else {
return;
};
match tls {
Some(backend) => match backend.accept(raw) {
Ok(conn) => ws_session(conn, uid, addr, true, core, shutdown, cfg),
Err(_) => {
let _ = shutdown.shutdown(Shutdown::Both);
}
},
None => ws_session(raw, uid, addr, false, core, shutdown, cfg),
}
}
/// The result of a successful handshake.
struct Handshake {
real_ip: Option<IpAddr>,
secure: bool,
binary: bool,
}
/// Drive one WebSocket connection: handshake, then frame IRC lines both ways until
/// close/EOF/idle-timeout or the core drops us.
fn ws_session<S: WsStream>(
mut stream: S,
uid: Uid,
addr: SocketAddr,
tls_secure: bool,
core: Sender<Event>,
shutdown: TcpStream,
cfg: WsConfig,
) {
// --- 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) {
Ok(h) => h,
Err(_) => {
let _ = shutdown.shutdown(Shutdown::Both);
return;
}
};
let real_addr = hs
.real_ip
.map(|ip| SocketAddr::new(ip, addr.port()))
.unwrap_or(addr);
let secure = tls_secure || hs.secure;
let send_opcode = if hs.binary { OP_BIN } else { OP_TEXT };
let (out_tx, out_rx) = std::sync::mpsc::channel::<String>();
if core
.send(Event::Connect {
uid,
addr: real_addr,
out: OutSink::Thread(out_tx),
sock: Some(shutdown),
secure,
certfp: None,
link: false,
outbound: false,
websocket: true,
})
.is_err()
{
stream.shutdown();
return;
}
// --- framed I/O loop (one thread, poll-read + drain-writes, like TLS) ---
let _ = stream.set_read_timeout(Some(POLL));
io_loop(&mut stream, uid, &core, &out_rx, &cfg, send_opcode);
// best-effort close handshake, then tell the core we're gone
let _ = stream.write_all(&encode(OP_CLOSE, &[]));
stream.shutdown();
let _ = core.send(Event::Disconnect { uid });
}
/// The read/deframe + write/frame loop. Returns when the connection should end.
fn io_loop<S: WsStream>(
stream: &mut S,
uid: Uid,
core: &Sender<Event>,
out_rx: &Receiver<String>,
cfg: &WsConfig,
send_opcode: u8,
) {
let mut acc: Vec<u8> = Vec::new(); // raw bytes awaiting a full frame
let mut msg: Vec<u8> = Vec::new(); // reassembled data message
let mut chunk = [0u8; 8192];
let mut last_rx = Instant::now();
let mut last_ping = Instant::now();
loop {
// 1) read
match stream.read(&mut chunk) {
Ok(0) => break, // EOF
Ok(n) => {
last_rx = Instant::now();
acc.extend_from_slice(&chunk[..n]);
loop {
match parse_frame(&acc) {
Ok(Some((frame, consumed))) => {
acc.drain(..consumed);
match frame.opcode {
OP_CLOSE => return,
OP_PING => {
let _ = stream.write_all(&encode(OP_PONG, &frame.payload));
}
OP_PONG => {}
OP_TEXT | OP_BIN => {
msg = frame.payload;
if frame.fin && !deliver(&mut msg, uid, core) {
return;
}
}
OP_CONT => {
msg.extend_from_slice(&frame.payload);
if msg.len() > MAX_MSG {
return;
}
if frame.fin && !deliver(&mut msg, uid, core) {
return;
}
}
_ => return, // unknown opcode
}
}
Ok(None) => break, // need more bytes
Err(()) => return, // protocol violation
}
}
}
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
// 2) drain queued output → frames
loop {
match out_rx.try_recv() {
Ok(line) => {
let mut payload = line.into_bytes();
payload.extend_from_slice(b"\r\n");
if stream.write_all(&encode(send_opcode, &payload)).is_err() {
return;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return, // core removed us
}
}
let _ = stream.flush();
// 3) keepalive + idle timeout
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() {
return;
}
}
if last_rx.elapsed() >= cfg.idle_timeout {
return; // dead connection
}
}
}
/// Split a completed data message into IRC lines and forward them; returns false if
/// the core has gone away. Clears `msg`.
fn deliver(msg: &mut Vec<u8>, uid: Uid, core: &Sender<Event>) -> bool {
let text = String::from_utf8_lossy(msg);
for piece in text.split('\n') {
let l = piece.trim_end_matches('\r');
if !l.is_empty()
&& core
.send(Event::Line {
uid,
line: l.to_string(),
})
.is_err()
{
msg.clear();
return false;
}
}
msg.clear();
true
}
/// One decoded WebSocket frame (payload already unmasked).
struct Frame {
fin: bool,
opcode: u8,
payload: Vec<u8>,
}
/// Parse one frame from the front of `buf`, returning it plus the bytes consumed.
/// `Ok(None)` = need more bytes; `Err(())` = protocol violation (caller closes).
/// Client frames must be masked.
fn parse_frame(buf: &[u8]) -> Result<Option<(Frame, usize)>, ()> {
if buf.len() < 2 {
return Ok(None);
}
let b0 = buf[0];
let b1 = buf[1];
let fin = b0 & 0x80 != 0;
let opcode = b0 & 0x0F;
let masked = b1 & 0x80 != 0;
if !masked {
return Err(()); // RFC 6455 §5.1: client→server frames MUST be masked
}
let len7 = (b1 & 0x7F) as usize;
let mut idx = 2;
let payload_len = match len7 {
126 => {
if buf.len() < idx + 2 {
return Ok(None);
}
let l = u16::from_be_bytes([buf[idx], buf[idx + 1]]) as usize;
idx += 2;
l
}
127 => {
if buf.len() < idx + 8 {
return Ok(None);
}
let mut a = [0u8; 8];
a.copy_from_slice(&buf[idx..idx + 8]);
idx += 8;
u64::from_be_bytes(a) as usize
}
n => n,
};
if payload_len > MAX_FRAME {
return Err(());
}
if buf.len() < idx + 4 + payload_len {
return Ok(None); // mask key (4) + payload not fully arrived
}
let mask = [buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]];
idx += 4;
let mut payload = buf[idx..idx + payload_len].to_vec();
for (i, b) in payload.iter_mut().enumerate() {
*b ^= mask[i % 4];
}
Ok(Some((
Frame {
fin,
opcode,
payload,
},
idx + payload_len,
)))
}
/// Encode a server frame (FIN set, never masked).
fn encode(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 10);
out.push(0x80 | opcode);
let n = payload.len();
if n < 126 {
out.push(n as u8);
} else if n <= 0xFFFF {
out.push(126);
out.extend_from_slice(&(n as u16).to_be_bytes());
} else {
out.push(127);
out.extend_from_slice(&(n as u64).to_be_bytes());
}
out.extend_from_slice(payload);
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 headers (bounded)
let mut buf = Vec::new();
let mut chunk = [0u8; 2048];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
if buf.len() > 16 * 1024 {
return Err(io::Error::other("headers too large"));
}
let n = stream.read(&mut chunk)?;
if n == 0 {
return Err(io::Error::other("eof in handshake"));
}
buf.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buf).into_owned();
let first = head.lines().next().unwrap_or("");
if !first
.split_whitespace()
.next()
.is_some_and(|m| m.eq_ignore_ascii_case("GET"))
{
return Err(io::Error::other("not a GET"));
}
let hdr = |name: &str| header(&head, name);
if !hdr("upgrade").is_some_and(|v| v.to_ascii_lowercase().contains("websocket"))
|| !hdr("connection").is_some_and(|v| v.to_ascii_lowercase().contains("upgrade"))
{
return Err(io::Error::other("missing upgrade"));
}
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)) {
let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return Err(io::Error::other("origin rejected"));
}
}
// subprotocol: prefer text.ircv3.net; accept binary.ircv3.net
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") {
(Some("binary.ircv3.net"), true)
} else {
(None, false)
};
// real IP / scheme from a trusted reverse proxy
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);
}
}
secure = hdr("x-forwarded-proto").is_some_and(|v| v.eq_ignore_ascii_case("https"));
}
// 101 response
let mut resp = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Accept: {}\r\n",
accept_key(key.trim())
);
if let Some(proto) = chosen {
resp.push_str(&format!("Sec-WebSocket-Protocol: {proto}\r\n"));
}
resp.push_str("\r\n");
stream.write_all(resp.as_bytes())?;
stream.flush()?;
Ok(Handshake {
real_ip,
secure,
binary,
})
}
/// Case-insensitive header lookup from a raw HTTP header block.
fn header(head: &str, name: &str) -> Option<String> {
head.lines().skip(1).find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim()
.eq_ignore_ascii_case(name)
.then(|| v.trim().to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accept_key_matches_rfc_example() {
// RFC 6455 §1.3 worked example
assert_eq!(
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn roundtrip_masked_text_frame() {
// build a masked client TEXT frame carrying "NICK bob"
let payload = b"NICK bob";
let mask = [0x12u8, 0x34, 0x56, 0x78];
let mut frame = vec![0x81, 0x80 | payload.len() as u8];
frame.extend_from_slice(&mask);
for (i, b) in payload.iter().enumerate() {
frame.push(b ^ mask[i % 4]);
}
let (parsed, consumed) = parse_frame(&frame).unwrap().unwrap();
assert!(
parsed.fin
&& parsed.opcode == OP_TEXT
&& parsed.payload == payload
&& consumed == frame.len()
);
}
#[test]
fn unmasked_client_frame_is_rejected() {
assert!(parse_frame(&[0x81, 0x03, b'a', b'b', b'c']).is_err());
}
#[test]
fn partial_frame_needs_more() {
assert!(parse_frame(&[0x81]).unwrap().is_none());
}
#[test]
fn encode_sets_fin_and_length() {
let f = encode(OP_TEXT, b"hi");
assert_eq!(f, vec![0x81, 0x02, b'h', b'i']);
}
}