A plaintext connection is upgraded when the network says to
STS was not read at all: `sts` never appeared in a CAP LS the client kept values from, and nothing acted on one. A network asking to be reached over TLS was answered in the clear, and the downgrade protection STS exists to give - that the *next* connection cannot be talked out of TLS by somebody in the middle of the first - was not there. Which half of the advertisement applies depends on how the connection was made, and the spec is emphatic about it. Over TLS, `duration=` is remembered in ~/.config/nobilis/irc-sts.toml, keyed by host and with an expiry; `duration=0` withdraws it, which is the only way out for a network that turns TLS off. Over plaintext only `port=` counts and nothing durable is learned, because an attacker who can rewrite a plaintext stream can write the policy too and a remembered forgery would outlive the attack. The upgrade cannot happen in place - registration is already under way and the way to a TLS port is a fresh connection - so the policy is stored and the attempt abandoned. The reconnect loop comes straight back and `establish` reads the policy on the way in. Registering first and reconnecting after would have sent NICK, USER and possibly a server password in the clear, which is the thing the network just asked us not to do. Two things this turned up while being written: - The effective TLS state has to be one value, not a flag passed alongside the account. `config.ssl` is read by more than the socket: the SASL path refuses to send credentials over a connection it believes is in the clear, and a separate flag would have left that check reading the old answer on exactly the connection STS had just upgraded. `establish` now derives an overridden copy of the account and everything downstream reads it. - The SASL path authenticates before `end_cap_and_register` runs, so the ordinary check would see STS only after the credentials had gone. A pre-flight CAP LS on plaintext connections closes that: `sasl_transport_ok` already refuses plaintext credentials unless somebody allowed it for the account, and this covers the case where they have, since that allowance was made about the network as it was. Closes #177. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+125
-6
@@ -286,6 +286,32 @@ pub(super) fn proxy_port(config: &IrcAccountConfig) -> u16 {
|
||||
/// OVERALL_ESTABLISH_TIMEOUT's doc comment above.
|
||||
pub(super) async fn establish(state: &AppState, config: &IrcAccountConfig) -> Result<(Sender, ClientStream, Option<NickservWait>)> {
|
||||
let account_id = config.account_id();
|
||||
|
||||
// A network that told us, over TLS, not to come back in plaintext.
|
||||
//
|
||||
// Applied here rather than at the account, because it is not the
|
||||
// account's setting to change: STS exists so that the *next* connection
|
||||
// cannot be talked down to plaintext by somebody in the middle of it, and
|
||||
// a policy that only applied when somebody remembered to tick a box would
|
||||
// protect nobody. The stored port comes with it - a network that moved
|
||||
// its TLS listener said so when it set the policy.
|
||||
//
|
||||
// Overridden into a copy of the account rather than carried alongside it,
|
||||
// so that everything downstream reads one truth. `config.ssl` is not only
|
||||
// used to open the socket: the SASL path refuses to send credentials over
|
||||
// a connection it believes is in the clear, and a flag passed separately
|
||||
// would have left that check reading the old answer on exactly the
|
||||
// connection STS had just upgraded.
|
||||
let mut config = config.clone();
|
||||
if !config.ssl {
|
||||
if let Some(policy) = state.irc_sts.policy(&config.host) {
|
||||
tracing::info!("irc[{account_id}]: STS in force for {}, using TLS on port {}", config.host, policy.port);
|
||||
state.runtime.report_progress(state, &account_id, &format!("{} requires TLS (STS) - connecting on port {} instead", config.host, policy.port));
|
||||
config.ssl = true;
|
||||
config.port = Some(policy.port);
|
||||
}
|
||||
}
|
||||
let config = &config;
|
||||
let port = config.port.unwrap_or(if config.ssl { 6697 } else { 6667 });
|
||||
|
||||
let irc_config = Config {
|
||||
@@ -337,6 +363,29 @@ pub(super) async fn establish(state: &AppState, config: &IrcAccountConfig) -> Re
|
||||
let mut stream = client.stream()?;
|
||||
let sender = client.sender();
|
||||
|
||||
// On a connection made in the clear, ask what the server offers before
|
||||
// saying anything at all.
|
||||
//
|
||||
// The ordinary STS check lives in `end_cap_and_register`, which on the
|
||||
// SASL path does not run until after the credentials have gone - and a
|
||||
// network that advertises STS is a network saying those credentials
|
||||
// should never have crossed a plaintext socket. `sasl_transport_ok`
|
||||
// already refuses to send them in the clear unless somebody has
|
||||
// explicitly allowed it for this account, so this closes the case where
|
||||
// they have: the allowance was made about the network as it was, and STS
|
||||
// is the network saying it has changed its mind.
|
||||
//
|
||||
// One extra round trip, on plaintext connections only, which are both the
|
||||
// rare case and the one with something to lose. A second `CAP LS` from
|
||||
// `end_cap_and_register` after this is legal and answered again.
|
||||
if !config.ssl {
|
||||
if let Some(raw) = offered_caps_raw(&sender, &mut stream).await {
|
||||
if let Some(port) = note_sts_policy(state, config, &raw.join(" ")) {
|
||||
bail!("{} requires TLS (STS) - reconnecting on port {port}", config.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the account is authenticated by the time registration finishes.
|
||||
// Ticking the SASL box is a request, not a guarantee: a server with no
|
||||
// SASL registers us normally, and then NickServ is still the way in.
|
||||
@@ -349,7 +398,7 @@ pub(super) async fn establish(state: &AppState, config: &IrcAccountConfig) -> Re
|
||||
// above are still worth asking for, and they have to be requested
|
||||
// before CAP END like any other.
|
||||
state.runtime.report_progress(state, &account_id, "Registering (NICK/USER)...");
|
||||
end_cap_and_register(&sender, &mut stream, config).await?;
|
||||
end_cap_and_register(state, &sender, &mut stream, config).await?;
|
||||
}
|
||||
|
||||
state.runtime.report_progress(state, &account_id, "Waiting for server welcome...");
|
||||
@@ -566,13 +615,55 @@ pub(super) fn caps_worth_requesting(offered: &str) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Acts on an `sts=` in a server's `CAP LS`, if there is one.
|
||||
///
|
||||
/// Which half applies depends on how this connection was made, and the spec is
|
||||
/// emphatic about it:
|
||||
///
|
||||
/// - Over **TLS**, `duration=` is remembered. That is the promise worth
|
||||
/// keeping, because it is what stops the *next* connection being talked down
|
||||
/// to plaintext by somebody in the middle of it. `duration=0` withdraws the
|
||||
/// policy and is obeyed - it is the only way out for a network that turns
|
||||
/// TLS off.
|
||||
/// - Over **plaintext**, only `port=` counts, and nothing is remembered.
|
||||
/// An attacker who can rewrite a plaintext stream can write the policy too,
|
||||
/// and a remembered forgery would outlive the attack. The upgrade is left to
|
||||
/// the reconnect: the policy is recorded for this host with a short life,
|
||||
/// the connection is dropped, and `establish` picks it up on the way back.
|
||||
fn note_sts_policy(state: &AppState, config: &IrcAccountConfig, caps: &str) -> Option<u16> {
|
||||
let advert = sts::sts_from_caps(caps)?;
|
||||
let account_id = config.account_id();
|
||||
if config.ssl {
|
||||
let duration = advert.duration?;
|
||||
let port = config.port.unwrap_or(6697);
|
||||
tracing::info!("irc[{account_id}]: STS for {} - TLS on port {port} for {duration}s", config.host);
|
||||
state.irc_sts.remember(&config.host, port, duration);
|
||||
None
|
||||
} else {
|
||||
let port = advert.port?;
|
||||
// A policy learned in the clear is only good enough to get us onto
|
||||
// the encrypted port once, which is why it is stored with a short
|
||||
// life: the duration that actually binds is read from the
|
||||
// advertisement that arrives over TLS, on the connection this one is
|
||||
// about to be replaced by.
|
||||
tracing::info!("irc[{account_id}]: STS offered over plaintext, reconnecting to port {port} over TLS");
|
||||
state.irc_sts.remember(&config.host, port, 60);
|
||||
Some(port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes capability negotiation and sends the ordinary NICK/USER pair.
|
||||
///
|
||||
/// Shared by the authenticated path and the fell-back-to-nothing path because
|
||||
/// both owe the server a CAP END: having asked for capabilities, registration
|
||||
/// does not proceed until we say we are finished asking, and a server left
|
||||
/// waiting for that just sits there until the establish timeout fires.
|
||||
pub(super) async fn end_cap_and_register(sender: &Sender, stream: &mut ClientStream, config: &IrcAccountConfig) -> Result<()> {
|
||||
pub(super) async fn end_cap_and_register(
|
||||
state: &AppState,
|
||||
sender: &Sender,
|
||||
stream: &mut ClientStream,
|
||||
config: &IrcAccountConfig,
|
||||
) -> Result<()> {
|
||||
// Ask what this server has before asking it for anything.
|
||||
//
|
||||
// This used to send one REQ per capability and not wait for the answers,
|
||||
@@ -592,7 +683,21 @@ pub(super) async fn end_cap_and_register(sender: &Sender, stream: &mut ClientStr
|
||||
// A server that answers no LS at all gets the old behaviour, since
|
||||
// something that ancient is exactly the case the atomicity worry was
|
||||
// about.
|
||||
let offered = offered_caps(sender, stream).await;
|
||||
let raw = offered_caps_raw(sender, stream).await;
|
||||
if let Some(raw) = raw.as_deref() {
|
||||
// An upgrade cannot be done in place: this is the middle of
|
||||
// registration, and the way to a TLS port is a fresh connection. So
|
||||
// the policy is stored and this attempt is abandoned - the reconnect
|
||||
// loop comes straight back, and `establish` reads the policy on the
|
||||
// way in. Registering first and reconnecting after would send NICK,
|
||||
// USER and possibly a server password in the clear, which is the
|
||||
// exact thing the network just asked us not to do.
|
||||
if let Some(port) = note_sts_policy(state, config, &raw.join(" ")) {
|
||||
bail!("{} requires TLS (STS) - reconnecting on port {port}", config.host);
|
||||
}
|
||||
}
|
||||
let offered: Option<Vec<String>> =
|
||||
raw.map(|raw| raw.iter().map(|cap| cap.split('=').next().unwrap_or(cap).to_string()).collect());
|
||||
match offered {
|
||||
Some(offered) => {
|
||||
let wanted: Vec<&str> = WANTED_CAPS.iter().copied().filter(|cap| offered.iter().any(|o| o == cap)).collect();
|
||||
@@ -636,6 +741,22 @@ pub(super) async fn end_cap_and_register(sender: &Sender, stream: &mut ClientStr
|
||||
/// returns None, and the caller falls back to asking for everything the way
|
||||
/// it always did.
|
||||
pub(super) async fn offered_caps(sender: &Sender, stream: &mut ClientStream) -> Option<Vec<String>> {
|
||||
offered_caps_raw(sender, stream).await.map(|raw| {
|
||||
raw.iter()
|
||||
// `cap=value` advertises a capability with parameters - SASL names
|
||||
// its mechanisms this way, STS its duration and port. The name is
|
||||
// what is requested.
|
||||
.map(|cap| cap.split('=').next().unwrap_or(cap).to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The offer as the server wrote it, values and all.
|
||||
///
|
||||
/// Separate from `offered_caps` because two callers want two different things
|
||||
/// out of one answer: negotiation wants names to compare against
|
||||
/// `WANTED_CAPS`, and STS wants the value it would otherwise have thrown away.
|
||||
pub(super) async fn offered_caps_raw(sender: &Sender, stream: &mut ClientStream) -> Option<Vec<String>> {
|
||||
if sender.send_cap_ls(NegotiationVersion::V302).is_err() {
|
||||
return None;
|
||||
}
|
||||
@@ -648,9 +769,7 @@ pub(super) async fn offered_caps(sender: &Sender, stream: &mut ClientStream) ->
|
||||
let more = param.as_deref() == Some("*");
|
||||
let list = if more { suffix.as_deref().unwrap_or("") } else { cap_list(param.as_deref(), suffix.as_deref()) };
|
||||
for cap in list.split_whitespace() {
|
||||
// `cap=value` advertises a capability with parameters - SASL
|
||||
// names its mechanisms this way. The name is what is requested.
|
||||
offered.push(cap.split('=').next().unwrap_or(cap).to_string());
|
||||
offered.push(cap.to_string());
|
||||
}
|
||||
if !more {
|
||||
tracing::debug!("irc: server offers {} capabilities: {}", offered.len(), offered.join(" "));
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod send;
|
||||
pub mod typing;
|
||||
pub mod dcc;
|
||||
pub mod nickserv;
|
||||
pub mod sts;
|
||||
pub mod sasl;
|
||||
|
||||
pub use connect::*;
|
||||
|
||||
@@ -345,7 +345,7 @@ pub(super) async fn register_with_sasl(state: &AppState, account_id: &str, sende
|
||||
|
||||
if !offered {
|
||||
state.runtime.report_progress(state, account_id, "Server has no SASL; registering normally...");
|
||||
end_cap_and_register(sender, stream, config).await?;
|
||||
end_cap_and_register(state, sender, stream, config).await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ pub(super) async fn register_with_sasl(state: &AppState, account_id: &str, sende
|
||||
tried.push(mechanism);
|
||||
match attempt_sasl(state, account_id, sender, stream, config, mechanism).await {
|
||||
Ok(()) => {
|
||||
end_cap_and_register(sender, stream, config).await?;
|
||||
end_cap_and_register(state, sender, stream, config).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(SaslRefusal::Fatal(e)) => return Err(e),
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Strict Transport Security for IRC, as IRCv3 defines it.
|
||||
//!
|
||||
//! A network advertises `sts` in its `CAP LS` with two parameters, and which
|
||||
//! of them applies depends entirely on how the connection carrying the
|
||||
//! advertisement was made:
|
||||
//!
|
||||
//! - Over **plaintext**, only `port=` counts. It means "come back on this port
|
||||
//! over TLS". Nothing is remembered from a plaintext connection, because an
|
||||
//! attacker who can rewrite the plaintext stream can write the policy too,
|
||||
//! and a remembered forgery would outlive the attack.
|
||||
//! - Over **TLS**, only `duration=` counts. It means "for this many seconds,
|
||||
//! refuse to talk to me any other way". That is the half worth persisting,
|
||||
//! and it is what protects the *next* connection from being talked down to
|
||||
//! plaintext by somebody sitting in the middle of it.
|
||||
//!
|
||||
//! `duration=0` over TLS is a network withdrawing its policy, and is obeyed -
|
||||
//! it is the only way out for a network that turns TLS off, and ignoring it
|
||||
//! would strand every client that ever connected.
|
||||
//!
|
||||
//! Kept in a file of its own rather than in the account, because it is not a
|
||||
//! setting somebody chose: it is something a network said, with an expiry, and
|
||||
//! it has to survive a client that is upgraded or reinstalled between two
|
||||
//! connections or it protects nothing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What a server's `sts=` value said. Both fields are optional because a
|
||||
/// server sends the one that applies to the connection it is speaking over.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct StsAdvert {
|
||||
pub duration: Option<u64>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Reads the value half of an `sts=...` capability.
|
||||
///
|
||||
/// The parameters are comma-separated `key=value` pairs, in either order, and
|
||||
/// a server may send keys this does not know - those are skipped rather than
|
||||
/// making the whole advertisement unreadable, which is what lets the spec add
|
||||
/// one later without breaking clients that predate it.
|
||||
pub fn parse_sts(value: &str) -> StsAdvert {
|
||||
let mut out = StsAdvert::default();
|
||||
for part in value.split(',') {
|
||||
let Some((key, val)) = part.split_once('=') else { continue };
|
||||
match key.trim() {
|
||||
"duration" => out.duration = val.trim().parse().ok(),
|
||||
"port" => out.port = val.trim().parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Finds the `sts` token in a `CAP LS` list and reads its value.
|
||||
///
|
||||
/// Returns `None` for a server that does not advertise it, and for one that
|
||||
/// advertises a bare `sts` with no parameters - which says nothing actionable
|
||||
/// and is treated as if it had not been said.
|
||||
pub fn sts_from_caps(list: &str) -> Option<StsAdvert> {
|
||||
list.split_whitespace()
|
||||
.find_map(|tok| tok.strip_prefix("sts="))
|
||||
.map(parse_sts)
|
||||
.filter(|a| a.duration.is_some() || a.port.is_some())
|
||||
}
|
||||
|
||||
/// One network's policy: the port it wants to be reached on, and when the
|
||||
/// promise runs out.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
pub struct StsPolicy {
|
||||
pub port: u16,
|
||||
/// Unix seconds. Past this, the policy is gone and a plaintext connection
|
||||
/// is somebody's own business again.
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct StsPrefs {
|
||||
#[serde(default)]
|
||||
pub hosts: BTreeMap<String, StsPolicy>,
|
||||
}
|
||||
|
||||
pub struct StsStore {
|
||||
path: PathBuf,
|
||||
inner: Mutex<StsPrefs>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl StsStore {
|
||||
pub fn open(path: PathBuf) -> Self {
|
||||
let inner = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| toml::from_str(&t).ok())
|
||||
// A file that will not parse must not stop the daemon starting.
|
||||
// No policies is a working configuration, and the worst it costs
|
||||
// is one connection made the way the account was configured.
|
||||
.unwrap_or_default();
|
||||
Self { path, inner: Mutex::new(inner) }
|
||||
}
|
||||
|
||||
/// This host's policy, if it has one that has not run out.
|
||||
///
|
||||
/// Host names are matched case-insensitively, because a policy learned
|
||||
/// from `irc.Example.NET` has to apply when somebody types the same name
|
||||
/// in lowercase - the two are one network, and an STS that could be
|
||||
/// stepped around by changing capitalisation would not be worth having.
|
||||
pub fn policy(&self, host: &str) -> Option<StsPolicy> {
|
||||
let policy = *self.inner.lock().unwrap().hosts.get(&host.to_ascii_lowercase())?;
|
||||
(policy.expires_at > now_secs()).then_some(policy)
|
||||
}
|
||||
|
||||
/// Records what a network said over TLS. A zero duration withdraws it.
|
||||
pub fn remember(&self, host: &str, port: u16, duration: u64) {
|
||||
self.update(|prefs| {
|
||||
let key = host.to_ascii_lowercase();
|
||||
if duration == 0 {
|
||||
prefs.hosts.remove(&key);
|
||||
} else {
|
||||
prefs.hosts.insert(key, StsPolicy { port, expires_at: now_secs().saturating_add(duration) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn update(&self, f: impl FnOnce(&mut StsPrefs)) {
|
||||
let snapshot = {
|
||||
let mut prefs = self.inner.lock().unwrap();
|
||||
f(&mut prefs);
|
||||
// Expired policies are dropped whenever the file is rewritten, so
|
||||
// it does not accumulate a row per network ever connected to.
|
||||
let now = now_secs();
|
||||
prefs.hosts.retain(|_, p| p.expires_at > now);
|
||||
prefs.clone()
|
||||
};
|
||||
if let Some(dir) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(text) = toml::to_string_pretty(&snapshot) {
|
||||
let _ = std::fs::write(&self.path, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_both_parameters_in_either_order() {
|
||||
assert_eq!(parse_sts("duration=2592000,port=6697"), StsAdvert { duration: Some(2592000), port: Some(6697) });
|
||||
assert_eq!(parse_sts("port=6697,duration=300"), StsAdvert { duration: Some(300), port: Some(6697) });
|
||||
}
|
||||
|
||||
/// A key this does not know does not make the rest unreadable - the spec
|
||||
/// is allowed to grow one.
|
||||
#[test]
|
||||
fn an_unknown_parameter_is_stepped_over() {
|
||||
assert_eq!(parse_sts("preload,duration=60,something=else"), StsAdvert { duration: Some(60), port: None });
|
||||
}
|
||||
|
||||
/// Zero is a real value, not a missing one: it is how a network withdraws
|
||||
/// its policy, and reading it as "unset" would make that impossible.
|
||||
#[test]
|
||||
fn a_zero_duration_is_a_value() {
|
||||
assert_eq!(parse_sts("duration=0").duration, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_sts_among_other_capabilities() {
|
||||
let caps = "server-time sts=duration=60,port=6697 away-notify";
|
||||
assert_eq!(sts_from_caps(caps), Some(StsAdvert { duration: Some(60), port: Some(6697) }));
|
||||
assert_eq!(sts_from_caps("server-time away-notify"), None);
|
||||
// A bare `sts` says nothing actionable.
|
||||
assert_eq!(sts_from_caps("server-time sts away-notify"), None);
|
||||
}
|
||||
|
||||
/// Each test gets its own directory: they run in one process, so a
|
||||
/// shared path would have them reading each other's policies.
|
||||
fn store(name: &str) -> (StsStore, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("moho-sts-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
(StsStore::open(dir.join("irc-sts.toml")), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_policy_is_remembered_and_read_back_whatever_the_capitalisation() {
|
||||
let (s, dir) = store("caps");
|
||||
s.remember("irc.Example.NET", 6697, 3600);
|
||||
assert_eq!(s.policy("irc.example.net").map(|p| p.port), Some(6697));
|
||||
assert_eq!(s.policy("IRC.EXAMPLE.NET").map(|p| p.port), Some(6697));
|
||||
assert!(s.policy("irc.other.net").is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The only way out for a network that turns TLS off.
|
||||
#[test]
|
||||
fn a_zero_duration_withdraws_the_policy() {
|
||||
let (s, dir) = store("withdraw");
|
||||
s.remember("irc.example.net", 6697, 3600);
|
||||
s.remember("irc.example.net", 6697, 0);
|
||||
assert!(s.policy("irc.example.net").is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_policy_that_has_run_out_is_not_a_policy() {
|
||||
let (s, dir) = store("expired");
|
||||
s.remember("irc.example.net", 6697, 3600);
|
||||
// Reach in and age it rather than sleeping.
|
||||
s.inner.lock().unwrap().hosts.get_mut("irc.example.net").unwrap().expires_at = 1;
|
||||
assert!(s.policy("irc.example.net").is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_policy_survives_being_written_and_reopened() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-sts-{}-reopen", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("irc-sts.toml");
|
||||
StsStore::open(path.clone()).remember("irc.example.net", 6697, 3600);
|
||||
assert_eq!(StsStore::open(path).policy("irc.example.net").map(|p| p.port), Some(6697));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub(super) fn simulated_daemon(name: &str) -> AppState {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(dir.join("ignores.toml"))),
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ mod live {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
@@ -183,6 +184,7 @@ mod live {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
@@ -295,6 +297,7 @@ mod live {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
@@ -452,6 +455,7 @@ mod live {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
@@ -608,6 +612,7 @@ mod live {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
@@ -575,6 +575,7 @@ mod tests {
|
||||
voice: Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: Arc::new(crate::audio::VoicePrefsStore::open(data_dir.join("voice.toml"))),
|
||||
dcc_prefs: Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(data_dir.join("dcc.toml"))),
|
||||
irc_sts: Arc::new(crate::backend::irc::sts::StsStore::open(data_dir.join("irc-sts.toml"))),
|
||||
highlights: Arc::new(crate::highlights::HighlightStore::open(data_dir.join("highlights.toml"))),
|
||||
ignores: Arc::new(crate::ignores::IgnoreStore::open(data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
@@ -286,6 +286,7 @@ async fn run() -> Result<()> {
|
||||
voice: Arc::new(backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: Arc::new(crate::audio::VoicePrefsStore::open(opts.data_dir.join("voice.toml"))),
|
||||
dcc_prefs: Arc::new(backend::irc::dcc::DccPrefsStore::open(opts.data_dir.join("dcc.toml"))),
|
||||
irc_sts: Arc::new(backend::irc::sts::StsStore::open(opts.data_dir.join("irc-sts.toml"))),
|
||||
highlights: Arc::new(highlights::HighlightStore::open(opts.data_dir.join("highlights.toml"))),
|
||||
ignores: Arc::new(ignores::IgnoreStore::open(opts.data_dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
@@ -3812,6 +3812,7 @@ mod tests {
|
||||
voice: std::sync::Arc::new(crate::backend::discord::voice::VoiceState::new()),
|
||||
voice_prefs: std::sync::Arc::new(crate::audio::VoicePrefsStore::open(dir.join("voice.toml"))),
|
||||
dcc_prefs: std::sync::Arc::new(crate::backend::irc::dcc::DccPrefsStore::open(dir.join("dcc.toml"))),
|
||||
irc_sts: std::sync::Arc::new(crate::backend::irc::sts::StsStore::open(dir.join("irc-sts.toml"))),
|
||||
highlights: std::sync::Arc::new(crate::highlights::HighlightStore::open(dir.join("highlights.toml"))),
|
||||
ignores: std::sync::Arc::new(crate::ignores::IgnoreStore::open(dir.join("ignores.toml"))),
|
||||
};
|
||||
|
||||
@@ -31,6 +31,10 @@ pub struct AppState {
|
||||
/// so a deliberate mute is still in force after a restart.
|
||||
pub voice_prefs: Arc<crate::audio::VoicePrefsStore>,
|
||||
pub dcc_prefs: Arc<crate::backend::irc::dcc::DccPrefsStore>,
|
||||
/// Which IRC networks have told us, over TLS, never to come back in
|
||||
/// plaintext - and until when. Not a setting somebody chose, which is why
|
||||
/// it is here rather than on the account: see backend/irc/sts.rs.
|
||||
pub irc_sts: Arc<crate::backend::irc::sts::StsStore>,
|
||||
/// The words that make a message worth being told about, besides your
|
||||
/// name. Here rather than in a window because this is where a message is
|
||||
/// decided to be a highlight - see highlights.rs.
|
||||
|
||||
Reference in New Issue
Block a user