websocket: native RFC 6455 transport (ws:// + wss://) — handshake, framing, ping/pong keepalive, idle timeout, origin/proxy flags, via_websocket
This commit is contained in:
parent
97b0d8ad89
commit
19157e0722
7 changed files with 633 additions and 0 deletions
|
|
@ -27,6 +27,7 @@ pub enum Event {
|
||||||
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
|
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
|
||||||
link: bool, // a server-to-server connection, not a client
|
link: bool, // a server-to-server connection, not a client
|
||||||
outbound: bool, // (link) we dialed them
|
outbound: bool, // (link) we dialed them
|
||||||
|
websocket: bool, // arrived over the WebSocket transport
|
||||||
},
|
},
|
||||||
Line {
|
Line {
|
||||||
uid: Uid,
|
uid: Uid,
|
||||||
|
|
@ -114,11 +115,17 @@ impl Ircd {
|
||||||
certfp,
|
certfp,
|
||||||
link,
|
link,
|
||||||
outbound,
|
outbound,
|
||||||
|
websocket,
|
||||||
} => {
|
} => {
|
||||||
if link {
|
if link {
|
||||||
self.server.add_link(uid, addr, out, sock, outbound);
|
self.server.add_link(uid, addr, out, sock, outbound);
|
||||||
} else {
|
} else {
|
||||||
self.server.add_conn(uid, addr, out, sock, secure, certfp);
|
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 } => {
|
Event::Line { uid, line } => {
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,5 @@ pub mod socketengine;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
|
pub mod websocket;
|
||||||
pub mod xline;
|
pub mod xline;
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ fn main() {
|
||||||
// optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc)
|
// optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc)
|
||||||
echoircd::modules::rpc::maybe_start(&cfg, tx.clone());
|
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)
|
// dial any autoconnect uplinks (after a short delay so the peer can boot)
|
||||||
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
||||||
let addr = format!("{}:{}", block.ip, block.port);
|
let addr = format!("{}:{}", block.ip, block.port);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ fn full(s: &Server, uid: Uid) -> String {
|
||||||
("channels", format!("[{}]", channels.join(","))),
|
("channels", format!("[{}]", channels.join(","))),
|
||||||
("oper", u.flags.oper.to_string()),
|
("oper", u.flags.oper.to_string()),
|
||||||
("secure", u.secure.to_string()),
|
("secure", u.secure.to_string()),
|
||||||
|
("websocket", u.flags.via_websocket.to_string()),
|
||||||
];
|
];
|
||||||
if let Some(acct) = &u.account {
|
if let Some(acct) = &u.account {
|
||||||
fields.push(("account", qstr(acct)));
|
fields.push(("account", qstr(acct)));
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
|
||||||
certfp: None,
|
certfp: None,
|
||||||
link: false,
|
link: false,
|
||||||
outbound: false,
|
outbound: false,
|
||||||
|
websocket: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
|
@ -384,6 +385,7 @@ pub fn accept_loop(
|
||||||
certfp: None,
|
certfp: None,
|
||||||
link,
|
link,
|
||||||
outbound: false,
|
outbound: false,
|
||||||
|
websocket: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
|
@ -432,6 +434,7 @@ pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
|
||||||
certfp: None,
|
certfp: None,
|
||||||
link: true,
|
link: true,
|
||||||
outbound: true,
|
outbound: true,
|
||||||
|
websocket: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
|
@ -516,6 +519,7 @@ fn tls_conn(
|
||||||
certfp,
|
certfp,
|
||||||
link,
|
link,
|
||||||
outbound: false,
|
outbound: false,
|
||||||
|
websocket: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ pub struct UserFlags {
|
||||||
pub deny_uncommon: bool, // +c (only users sharing a channel may PM you)
|
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 nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change)
|
||||||
pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups)
|
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
|
pub away: Option<String>, // AWAY message, if set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
616
src/websocket.rs
Normal file
616
src/websocket.rs
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue