brand: per-SNI server/network identity (welcome, ISUPPORT NETWORK, numeric source prefix)

This commit is contained in:
Jean Chevronnet 2026-08-24 23:29:15 +00:00
parent f36f803d42
commit b6ada854cc
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
10 changed files with 153 additions and 27 deletions

View file

@ -56,6 +56,16 @@ pub struct OperBlock {
pub oper_type: Option<String>,
}
/// 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<String>, // PEM private key
pub motd: Vec<String>,
pub opers: Vec<OperBlock>, // oper logins (see OperBlock)
pub brands: Vec<BrandBlock>, // per-SNI server/network branding
pub cloak_key: Option<String>, // 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 = <host> [servername=<sv>] [network=<nw>]
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('[') {

View file

@ -29,6 +29,7 @@ pub enum Event {
secure: bool,
certfp: Option<String>, // TLS client-cert fingerprint (clients only)
tls_info: Option<String>, // negotiated TLS version/group/cipher (WHOIS 671)
sni: Option<String>, // 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;

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -168,6 +168,7 @@ pub struct Server {
pub channels: HashMap<String, Channel>, // lower name -> channel
pub events: VecDeque<Hook>,
pub opers: Vec<crate::config::OperBlock>, // oper logins from config
pub brands: Vec<crate::config::BrandBlock>, // per-SNI server/network branding
pub cloak_key: Option<String>, // 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<String>,
tls_info: Option<String>,
sni: Option<String>,
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<String> {
pub fn isupport_lines(&self, network: &str) -> Vec<String> {
// 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<String> = 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()
.and_then(|u| u.brand_server.as_deref())
.unwrap_or(&self.name)
}
})
.unwrap_or_else(|| "*".to_string());
self.send(
uid,
format!(":{} {:03} {} {}", self.name, code, target, rest),
);
/// 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<String>, Option<String>) {
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}");

View file

@ -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<Event>,
) -> bool {
let mut close = false;
let mut connect: Option<(Uid, SocketAddr, u16, Option<String>, Option<String>, OutSink)> =
None;
let mut connect: Option<(
Uid,
SocketAddr,
u16,
Option<String>,
Option<String>,
Option<String>,
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<usize, Conn>, 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<Event>, counter: Arc<AtomicU64>, 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::<String>();
if core
.send(Event::Connect {
@ -1305,6 +1319,7 @@ fn tls_conn(
secure: true,
certfp,
tls_info,
sni,
local_port,
link,
outbound: false,

View file

@ -46,6 +46,10 @@ pub trait TlsConn: Send {
fn tls_info(&self) -> Option<String> {
None
}
/// The SNI hostname the client requested during the TLS handshake, if any.
fn sni(&self) -> Option<String> {
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<String> {
None
}
/// The SNI hostname the client requested during the TLS handshake, if any.
fn sni(&self) -> Option<String> {
None
}
fn shutdown(&mut self);
}
@ -258,6 +266,9 @@ impl TlsSession for OpensslSession {
fn tls_info(&self) -> Option<String> {
openssl_tls_info(self.0.ssl())
}
fn sni(&self) -> Option<String> {
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<String> {
openssl_tls_info(self.0.ssl())
}
fn sni(&self) -> Option<String> {
self.0.ssl().servername(NameType::HOST_NAME).map(String::from)
}
}

View file

@ -231,6 +231,8 @@ pub struct User {
pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo)
pub certfp: Option<String>, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP)
pub tls_info: Option<String>, // negotiated TLS version/group/cipher (WHOIS 671)
pub brand_server: Option<String>, // per-SNI display server name (None = global)
pub brand_network: Option<String>, // per-SNI display network name (None = global)
pub account: Option<String>, // 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).

View file

@ -279,6 +279,7 @@ fn ws_session<S: WsStream>(
secure,
certfp: None,
tls_info: None,
sni: None,
local_port,
link: false,
outbound: false,