From 6cf9f01ff861d2e325f7614d4f916e589b0402f3 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 21:48:05 +0000 Subject: [PATCH] =?UTF-8?q?ircv3=20STS:=20advertise=20sts=3D=20(port=20on?= =?UTF-8?q?=20plaintext,=20duration/preload=20on=20tls)=20=E2=80=94=20the?= =?UTF-8?q?=20modern=20tls-upgrade=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.rs | 20 ++++++++++++++++++++ src/coremods/core_rehash.rs | 3 +++ src/coremods/core_user.rs | 14 ++++++-------- src/server.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index 9081571..7b2270f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -76,6 +76,9 @@ pub struct Config { pub dnsbl_reason: String, // ban reason for a DNSBL hit pub sasl_server: String, // linked services server that handles SASL ("" = none) pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) + pub sts_duration: u64, // IRCv3 STS: seconds a client must keep using TLS (0 = STS off) + pub sts_port: u16, // TLS port to advertise in STS (0 = derive from bind_tls) + pub sts_preload: bool, // STS preload flag } impl Default for Config { @@ -104,6 +107,9 @@ impl Default for Config { dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(), sasl_server: String::new(), webirc: Vec::new(), + sts_duration: 0, + sts_port: 0, + sts_preload: false, } } } @@ -237,6 +243,9 @@ impl Config { "dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(), "dnsbl_reason" => c.dnsbl_reason = v.to_string(), "sasl_server" | "sasl_target" => c.sasl_server = v.to_string(), + "sts_duration" => c.sts_duration = v.parse().unwrap_or(0), + "sts_port" => c.sts_port = v.parse().unwrap_or(0), + "sts_preload" => c.sts_preload = matches!(v, "on" | "yes" | "true" | "1"), "webirc" => { // webirc = [gateway-name] [ip-mask] let mut it = v.split_whitespace(); @@ -249,5 +258,16 @@ impl Config { _ => {} } } + // STS advertises a TLS port to insecure clients; default it to the TLS listener's. + if c.sts_duration > 0 && c.sts_port == 0 { + if let Some(p) = c + .bind_tls + .as_deref() + .and_then(|b| b.rsplit(':').next()) + .and_then(|p| p.parse::().ok()) + { + c.sts_port = p; + } + } } } diff --git a/src/coremods/core_rehash.rs b/src/coremods/core_rehash.rs index 51f1777..6a5359e 100644 --- a/src/coremods/core_rehash.rs +++ b/src/coremods/core_rehash.rs @@ -67,6 +67,9 @@ impl Command for Rehash { s.dnsbl_action = fresh.dnsbl_action; s.dnsbl_reason = fresh.dnsbl_reason; s.sasl_server = fresh.sasl_server; + s.sts_duration = fresh.sts_duration; + s.sts_port = fresh.sts_port; + s.sts_preload = fresh.sts_preload; s.announce("Server configuration reloaded."); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); } diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 4735773..d3360db 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -124,14 +124,12 @@ impl Command for Cap { u.cap = true; // hold registration until CAP END u.cap_302 |= cap302; } - s.send( - uid, - format!( - ":{} CAP {who} LS :{}", - s.name, - Caps::ls_line(cap302, secure) - ), - ); + let mut caps = Caps::ls_line(cap302, secure); + if let Some(sts) = s.sts_token(secure) { + caps.push(' '); + caps.push_str(&sts); // IRCv3 STS — advertised, not REQ-able + } + s.send(uid, format!(":{} CAP {who} LS :{caps}", s.name)); } "REQ" => { if let Some(u) = s.users.get_mut(&uid) { diff --git a/src/server.rs b/src/server.rs index 9b4c154..65b047a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -144,6 +144,9 @@ pub struct Server { pub dnsbl_reason: String, // ban reason on a DNSBL hit pub sasl_server: String, // services server that handles SASL pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) + pub sts_duration: u64, // IRCv3 STS: TLS-only duration (0 = off) + pub sts_port: u16, // STS TLS port advertised to insecure clients + pub sts_preload: bool, // STS preload flag // labeled-response: while Some((uid, buf)), that client's own responses are // diverted into `buf` instead of the socket, so `on_line` can wrap them with // the command's `label` (single tag, BATCH, or ACK). RefCell because the @@ -193,6 +196,9 @@ impl Server { dnsbl_reason: cfg.dnsbl_reason, sasl_server: cfg.sasl_server, webirc: cfg.webirc, + sts_duration: cfg.sts_duration, + sts_port: cfg.sts_port, + sts_preload: cfg.sts_preload, label_capture: RefCell::new(None), history: HashMap::new(), read_markers: HashMap::new(), @@ -250,6 +256,26 @@ impl Server { } } + /// The IRCv3 `sts=` cap value for a connection, or `None` when STS is off. + /// Insecure clients get `port=` (reconnect over TLS); secure clients + /// get `duration=[,preload]` (remember to always use TLS). + pub fn sts_token(&self, secure: bool) -> Option { + if self.sts_duration == 0 { + return None; + } + if secure { + let mut v = format!("sts=duration={}", self.sts_duration); + if self.sts_preload { + v.push_str(",preload"); + } + Some(v) + } else if self.sts_port != 0 { + Some(format!("sts=port={}", self.sts_port)) + } else { + None + } + } + /// The read-marker identity for `uid`: their account when logged in (so markers /// are shared across their devices and survive reconnects), else a per-session /// key. `remove_user` prunes the session key on disconnect.