ircv3 STS: advertise sts= (port on plaintext, duration/preload on tls) — the modern tls-upgrade cap

This commit is contained in:
Jean Chevronnet 2026-08-08 21:48:05 +00:00
parent a70ee42aee
commit 6cf9f01ff8
4 changed files with 55 additions and 8 deletions

View file

@ -76,6 +76,9 @@ pub struct Config {
pub dnsbl_reason: String, // ban reason for a DNSBL hit pub dnsbl_reason: String, // ban reason for a DNSBL hit
pub sasl_server: String, // linked services server that handles SASL ("" = none) pub sasl_server: String, // linked services server that handles SASL ("" = none)
pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) 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 { impl Default for Config {
@ -104,6 +107,9 @@ impl Default for Config {
dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(), dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(),
sasl_server: String::new(), sasl_server: String::new(),
webirc: Vec::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_action" => c.dnsbl_action = v.to_ascii_lowercase(),
"dnsbl_reason" => c.dnsbl_reason = v.to_string(), "dnsbl_reason" => c.dnsbl_reason = v.to_string(),
"sasl_server" | "sasl_target" => c.sasl_server = 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" => {
// webirc = <password> [gateway-name] [ip-mask] // webirc = <password> [gateway-name] [ip-mask]
let mut it = v.split_whitespace(); 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::<u16>().ok())
{
c.sts_port = p;
}
}
} }
} }

View file

@ -67,6 +67,9 @@ impl Command for Rehash {
s.dnsbl_action = fresh.dnsbl_action; s.dnsbl_action = fresh.dnsbl_action;
s.dnsbl_reason = fresh.dnsbl_reason; s.dnsbl_reason = fresh.dnsbl_reason;
s.sasl_server = fresh.sasl_server; 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.announce("Server configuration reloaded.");
s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing"));
} }

View file

@ -124,14 +124,12 @@ impl Command for Cap {
u.cap = true; // hold registration until CAP END u.cap = true; // hold registration until CAP END
u.cap_302 |= cap302; u.cap_302 |= cap302;
} }
s.send( let mut caps = Caps::ls_line(cap302, secure);
uid, if let Some(sts) = s.sts_token(secure) {
format!( caps.push(' ');
":{} CAP {who} LS :{}", caps.push_str(&sts); // IRCv3 STS — advertised, not REQ-able
s.name, }
Caps::ls_line(cap302, secure) s.send(uid, format!(":{} CAP {who} LS :{caps}", s.name));
),
);
} }
"REQ" => { "REQ" => {
if let Some(u) = s.users.get_mut(&uid) { if let Some(u) = s.users.get_mut(&uid) {

View file

@ -144,6 +144,9 @@ pub struct Server {
pub dnsbl_reason: String, // ban reason on a DNSBL hit pub dnsbl_reason: String, // ban reason on a DNSBL hit
pub sasl_server: String, // services server that handles SASL pub sasl_server: String, // services server that handles SASL
pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) 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 // 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 // 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 // the command's `label` (single tag, BATCH, or ACK). RefCell because the
@ -193,6 +196,9 @@ impl Server {
dnsbl_reason: cfg.dnsbl_reason, dnsbl_reason: cfg.dnsbl_reason,
sasl_server: cfg.sasl_server, sasl_server: cfg.sasl_server,
webirc: cfg.webirc, webirc: cfg.webirc,
sts_duration: cfg.sts_duration,
sts_port: cfg.sts_port,
sts_preload: cfg.sts_preload,
label_capture: RefCell::new(None), label_capture: RefCell::new(None),
history: HashMap::new(), history: HashMap::new(),
read_markers: 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=<tlsport>` (reconnect over TLS); secure clients
/// get `duration=<n>[,preload]` (remember to always use TLS).
pub fn sts_token(&self, secure: bool) -> Option<String> {
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 /// 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 /// are shared across their devices and survive reconnects), else a per-session
/// key. `remove_user` prunes the session key on disconnect. /// key. `remove_user` prunes the session key on disconnect.