diff --git a/src/config.rs b/src/config.rs index 6825dd3..705475d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,16 @@ pub struct OperBlock { pub oper_type: Option, } +/// Per-SNI branding: a client that connected via `host` (TLS SNI) is shown +/// `servername`/`network` instead of the global ones — one daemon, multiple +/// network identities. Repeatable. +#[derive(Clone, Default)] +pub struct BrandBlock { + pub host: String, + pub servername: String, + pub network: String, +} + /// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's /// real host + IP. `ipmask` (empty = any) restricts which source addresses may use /// this block. @@ -116,6 +126,7 @@ pub struct Config { pub tls_key: Option, // PEM private key pub motd: Vec, pub opers: Vec, // oper logins (see OperBlock) + pub brands: Vec, // per-SNI server/network branding pub cloak_key: Option, // secret key for host cloaking (+x); None = off pub sid: String, // this server's 3-char server id (S2S) pub serverdesc: String, // this server's description @@ -147,6 +158,7 @@ impl Default for Config { tls_key: None, motd: Vec::new(), opers: Vec::new(), + brands: Vec::new(), cloak_key: None, sid: "0AA".to_string(), serverdesc: "echoIRCd server".to_string(), @@ -265,6 +277,24 @@ impl Config { } } "motd" => c.motd.push(v.to_string()), + "brand" => { + // brand = [servername=] [network=] + let mut it = v.split_whitespace(); + if let Some(host) = it.next() { + let mut b = BrandBlock { + host: host.to_ascii_lowercase(), + ..Default::default() + }; + for tok in it { + if let Some(s) = tok.strip_prefix("servername=") { + b.servername = s.to_string(); + } else if let Some(n) = tok.strip_prefix("network=") { + b.network = n.to_string(); + } + } + c.brands.push(b); + } + } "oper" => { let mut it = v.split_whitespace().peekable(); if let Some(n) = it.next() { @@ -632,6 +662,20 @@ fn emit_block(out: &mut String, name: &str, fields: &[(String, String)]) { emit_line(out, "cloak_cert_prefix", v); } } + "brand" => { + if let Some(host) = get("host") { + let mut line = host.to_string(); + if let Some(v) = get("servername") { + line.push_str(" servername="); + line.push_str(v); + } + if let Some(v) = get("network") { + line.push_str(" network="); + line.push_str(v); + } + emit_line(out, "brand", &line); + } + } "listen" => { if let (Some(ip), Some(port)) = (get("ip"), get("port")) { let addr = if ip.contains(':') && !ip.starts_with('[') { diff --git a/src/ircd.rs b/src/ircd.rs index 48f0d0c..edf21da 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -29,6 +29,7 @@ pub enum Event { secure: bool, certfp: Option, // TLS client-cert fingerprint (clients only) tls_info: Option, // negotiated TLS version/group/cipher (WHOIS 671) + sni: Option, // TLS SNI hostname the client used (per-SNI branding) local_port: u16, // the listener port the client connected to link: bool, // a server-to-server connection, not a client outbound: bool, // (link) we dialed them @@ -191,6 +192,7 @@ impl Ircd { secure, certfp, tls_info, + sni, local_port, link, outbound, @@ -200,7 +202,7 @@ impl Ircd { self.server.add_link(uid, addr, out, sock, outbound); } else { self.server - .add_conn(uid, addr, out, sock, secure, certfp, tls_info, local_port); + .add_conn(uid, addr, out, sock, secure, certfp, tls_info, sni, local_port); if websocket { if let Some(u) = self.server.users.get_mut(&uid) { u.flags.via_websocket = true; diff --git a/src/link.rs b/src/link.rs index 8309bc3..cfbc358 100644 --- a/src/link.rs +++ b/src/link.rs @@ -2828,6 +2828,8 @@ mod tests { secure: false, certfp: None, tls_info: None, + brand_server: None, + brand_network: None, account: None, signon: 0, nick_ts: 0, @@ -2918,6 +2920,8 @@ mod tests { secure: false, certfp: None, tls_info: None, + brand_server: None, + brand_network: None, account: None, signon: 0, nick_ts: 0, diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index b6a411d..19d44c1 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -375,6 +375,8 @@ mod tests { secure: false, certfp: None, tls_info: None, + brand_server: None, + brand_network: None, account: Some("reverse".into()), signon: 0, nick_ts: 0, diff --git a/src/s2s_sim.rs b/src/s2s_sim.rs index f8a5c68..bbfab85 100644 --- a/src/s2s_sim.rs +++ b/src/s2s_sim.rs @@ -75,6 +75,8 @@ impl Node { secure: false, certfp: None, tls_info: None, + brand_server: None, + brand_network: None, account: None, signon: 0, nick_ts: 0, diff --git a/src/server.rs b/src/server.rs index 5c48399..efc71c5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -168,6 +168,7 @@ pub struct Server { pub channels: HashMap, // lower name -> channel pub events: VecDeque, pub opers: Vec, // oper logins from config + pub brands: Vec, // per-SNI server/network branding pub cloak_key: Option, // host-cloaking key (see modules::cloak) pub line_ctags: String, // client-only tags of the line being handled // --- server-to-server (see crate::link) --- @@ -241,6 +242,7 @@ impl Server { channels: HashMap::default(), events: VecDeque::new(), opers: cfg.opers, + brands: cfg.brands, cloak_key: cfg.cloak_key, line_ctags: String::new(), sid: cfg.sid, @@ -316,6 +318,7 @@ impl Server { pub fn apply_config(&mut self, fresh: crate::config::Config) { self.motd = fresh.motd; self.opers = fresh.opers; + self.brands = fresh.brands; self.cloak_key = fresh.cloak_key; self.censor = fresh.censor; self.amu = fresh.amu; @@ -388,8 +391,10 @@ impl Server { secure: bool, certfp: Option, tls_info: Option, + sni: Option, local_port: u16, ) { + let (brand_server, brand_network) = self.resolve_brand(sni.as_deref()); let uuid = self.next_uuid(); self.uuid_local.insert(uuid.clone(), uid); let ip = addr.ip(); @@ -407,6 +412,8 @@ impl Server { secure, certfp, tls_info, + brand_server, + brand_network, account: None, signon: now(), nick_ts: now(), @@ -890,7 +897,7 @@ impl Server { /// the config-driven module tokens (ICON, FILEHOST). Each entry is a token block /// without the trailing `:are supported by this server`. Shared by the welcome /// burst and the `ISUPPORT` command (draft/extended-isupport). - pub fn isupport_lines(&self) -> Vec { + pub fn isupport_lines(&self, network: &str) -> Vec { // advertised limits mirror the (config-driven) values actually enforced let maxwatch = self.conf_num("maxwatch", crate::watch::WATCH_MAX); let maxmon = self.conf_num("maxmonitor", crate::watch::MONITOR_MAX); @@ -905,7 +912,7 @@ impl Server { let prefix = crate::modules::customprefix::isupport(include_oper); let mut tokens: Vec = format!( "CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,aGbcgjmnrsy ACCOUNTEXTBAN=a BOT=B WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} MODES={maxmodes} NETWORK={}", - self.network + network ) .split(' ') .map(String::from) @@ -925,7 +932,7 @@ impl Server { /// `draft/extended-isupport` + `batch`), wrap them in a `draft/isupport` BATCH so /// the multi-line set arrives atomically. pub fn send_isupport(&mut self, uid: Uid, batched: bool) { - let lines = self.isupport_lines(); + let lines = self.isupport_lines(self.disp_network(uid)); if batched { let nick = self .users @@ -956,22 +963,53 @@ impl Server { } } - pub fn numeric(&self, uid: Uid, code: u16, rest: &str) { - let target = self - .users + /// The server name shown to `uid`: its per-SNI brand, or the global name. + pub fn disp_name(&self, uid: Uid) -> &str { + self.users .get(&uid) - .map(|u| { - if u.nick.is_empty() { - "*".to_string() - } else { - u.nick.clone() - } - }) - .unwrap_or_else(|| "*".to_string()); - self.send( - uid, - format!(":{} {:03} {} {}", self.name, code, target, rest), - ); + .and_then(|u| u.brand_server.as_deref()) + .unwrap_or(&self.name) + } + + /// The network name shown to `uid`: its per-SNI brand, or the global network. + pub fn disp_network(&self, uid: Uid) -> &str { + self.users + .get(&uid) + .and_then(|u| u.brand_network.as_deref()) + .unwrap_or(&self.network) + } + + /// Resolve the per-SNI brand for a new connection: match its TLS SNI host + /// against the configured `brand` blocks, returning the display servername + + /// network (each `None` = no brand, use the globals). + fn resolve_brand(&self, sni: Option<&str>) -> (Option, Option) { + let host = match sni { + Some(h) if !h.is_empty() => h.to_ascii_lowercase(), + _ => return (None, None), + }; + for b in &self.brands { + if b.host == host { + let sv = (!b.servername.is_empty()).then(|| b.servername.clone()); + let nw = (!b.network.is_empty()).then(|| b.network.clone()); + return (sv, nw); + } + } + (None, None) + } + + pub fn numeric(&self, uid: Uid, code: u16, rest: &str) { + let line = { + let u = self.users.get(&uid); + let target = u + .map(|u| if u.nick.is_empty() { "*" } else { u.nick.as_str() }) + .unwrap_or("*"); + // per-SNI brand as the message source (falls back to the global name) + let srv = u + .and_then(|u| u.brand_server.as_deref()) + .unwrap_or(&self.name); + format!(":{srv} {code:03} {target} {rest}") + }; + self.send(uid, line); } /// Send a server notice to every operator who has snomask (+s) on. @@ -1547,6 +1585,8 @@ mod tests { secure: false, certfp: None, tls_info: None, + brand_server: None, + brand_network: None, account: None, signon: 0, nick_ts: 0, @@ -1894,7 +1934,7 @@ mod tests { #[test] fn isupport_advertises_bot_and_account_extban() { let s = srv(); - let joined = s.isupport_lines().join(" "); + let joined = s.isupport_lines(&s.network).join(" "); assert!(joined.contains("BOT=B"), "bot-mode letter: {joined}"); assert!(joined.contains("ACCOUNTEXTBAN=a"), "account-extban token: {joined}"); assert!(joined.contains("EXTBAN=,aG"), "'a' listed in EXTBAN: {joined}"); diff --git a/src/socketengine.rs b/src/socketengine.rs index fd8deef..a67c4d7 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -657,6 +657,7 @@ fn reactor_loop( secure: false, certfp: None, tls_info: None, + sni: None, local_port: a.local_port, link: false, outbound: false, @@ -789,8 +790,15 @@ fn try_handshake( core: &Sender, ) -> bool { let mut close = false; - let mut connect: Option<(Uid, SocketAddr, u16, Option, Option, OutSink)> = - None; + let mut connect: Option<( + Uid, + SocketAddr, + u16, + Option, + Option, + Option, + OutSink, + )> = None; if let Some(c) = conns.get_mut(&t) { if !c.handshaking { return true; @@ -801,10 +809,11 @@ fn try_handshake( c.handshaking = false; let certfp = sess.peer_cert_fp(); let tls_info = sess.tls_info(); + let sni = sess.sni(); connect = c .pending_out .take() - .map(|out| (c.uid, c.addr, c.local_port, certfp, tls_info, out)); + .map(|out| (c.uid, c.addr, c.local_port, certfp, tls_info, sni, out)); set_interest(poll, c, t); // handshake done: drop the extra WRITABLE } Ok(false) => return false, // still negotiating @@ -816,7 +825,7 @@ fn try_handshake( } else { return false; } - if let Some((uid, addr, local_port, certfp, tls_info, out)) = connect { + if let Some((uid, addr, local_port, certfp, tls_info, sni, out)) = connect { let _ = core.send(Event::Connect { uid, addr, @@ -825,6 +834,7 @@ fn try_handshake( secure: true, certfp, tls_info, + sni, local_port, link: false, outbound: false, @@ -948,6 +958,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: secure, certfp, tls_info: None, + sni: None, local_port, link: false, outbound: false, @@ -1102,6 +1113,7 @@ pub fn accept_loop( secure: false, certfp: None, tls_info: None, + sni: None, local_port, link, outbound: false, @@ -1187,6 +1199,7 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc, ma secure: false, certfp: None, tls_info: None, + sni: None, local_port: 0, link: true, outbound: true, @@ -1295,6 +1308,7 @@ fn tls_conn( }; let certfp = conn.peer_cert_fp(); let tls_info = conn.tls_info(); + let sni = conn.sni(); let (out_tx, out_rx) = mpsc::channel::(); if core .send(Event::Connect { @@ -1305,6 +1319,7 @@ fn tls_conn( secure: true, certfp, tls_info, + sni, local_port, link, outbound: false, diff --git a/src/tls.rs b/src/tls.rs index 852fc47..f772d9b 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -46,6 +46,10 @@ pub trait TlsConn: Send { fn tls_info(&self) -> Option { None } + /// The SNI hostname the client requested during the TLS handshake, if any. + fn sni(&self) -> Option { + None + } } /// A non-blocking TLS session the reactor drives itself over a mio socket. The @@ -79,6 +83,10 @@ pub trait TlsSession: Send { fn tls_info(&self) -> Option { None } + /// The SNI hostname the client requested during the TLS handshake, if any. + fn sni(&self) -> Option { + None + } fn shutdown(&mut self); } @@ -258,6 +266,9 @@ impl TlsSession for OpensslSession { fn tls_info(&self) -> Option { openssl_tls_info(self.0.ssl()) } + fn sni(&self) -> Option { + self.0.ssl().servername(NameType::HOST_NAME).map(String::from) + } fn shutdown(&mut self) { // best-effort TLS close_notify, then close the socket. Non-blocking, so a // WouldBlock just means the alert is queued — we don't wait for the peer's. @@ -292,4 +303,7 @@ impl TlsConn for OpensslConn { fn tls_info(&self) -> Option { openssl_tls_info(self.0.ssl()) } + fn sni(&self) -> Option { + self.0.ssl().servername(NameType::HOST_NAME).map(String::from) + } } diff --git a/src/users.rs b/src/users.rs index 8866e06..96d3939 100644 --- a/src/users.rs +++ b/src/users.rs @@ -231,6 +231,8 @@ pub struct User { pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo) pub certfp: Option, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP) pub tls_info: Option, // negotiated TLS version/group/cipher (WHOIS 671) + pub brand_server: Option, // per-SNI display server name (None = global) + pub brand_network: Option, // per-SNI display network name (None = global) pub account: Option, // logged-in account name (set by services) pub signon: u64, // unix secs at registration (WHOIS 317) pub nick_ts: u64, // unix secs the current nick was taken (nick-collision arbitration) @@ -460,12 +462,12 @@ impl Server { self.numeric( uid, RPL_WELCOME, - &format!(":Welcome to the {} IRC Network, {nick}", self.network), + &format!(":Welcome to the {} IRC Network, {nick}", self.disp_network(uid)), ); self.numeric( uid, RPL_YOURHOST, - &format!(":Your host is {}, running echoircd-{RELEASE}", self.name), + &format!(":Your host is {}, running echoircd-{RELEASE}", self.disp_name(uid)), ); self.numeric( uid, @@ -477,7 +479,7 @@ impl Server { RPL_MYINFO, &format!( "{} echoircd-{RELEASE} iowxsgBkDIHrRzWhc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKXwD", - self.name + self.disp_name(uid) ), ); // ISUPPORT (005): the fixed set + config-driven module tokens (ICON/FILEHOST). diff --git a/src/websocket.rs b/src/websocket.rs index 3fbc0a1..bc02e23 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -279,6 +279,7 @@ fn ws_session( secure, certfp: None, tls_info: None, + sni: None, local_port, link: false, outbound: false,