brand: per-SNI server/network identity (welcome, ISUPPORT NETWORK, numeric source prefix)
This commit is contained in:
parent
f36f803d42
commit
b6ada854cc
10 changed files with 153 additions and 27 deletions
|
|
@ -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('[') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
.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<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}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
src/tls.rs
14
src/tls.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ fn ws_session<S: WsStream>(
|
|||
secure,
|
||||
certfp: None,
|
||||
tls_info: None,
|
||||
sni: None,
|
||||
local_port,
|
||||
link: false,
|
||||
outbound: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue