diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index f0af9dc..e5ed922 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -12,7 +12,17 @@ impl Engine { let arg = parts.next().unwrap_or(""); let response = match cmd.as_str() { "VERSION" => format!("VERSION {}", crate::version::short()), - "PING" => format!("PING {arg}"), + "PING" => { + // Reflect the client's token, but cap it: an over-long token would + // push the client-facing line past 512 once the ircd expands our + // short service source into a full nick!user@host, stranding the + // closing \x01. A real PING token is a short timestamp. + let mut n = arg.len().min(200); + while !arg.is_char_boundary(n) { + n -= 1; + } + format!("PING {}", &arg[..n]) + } "TIME" => format!("TIME {}", echo_api::human_time(self.now_secs())), "CLIENTINFO" => "CLIENTINFO ACTION CLIENTINFO PING TIME VERSION".to_string(), _ => return None, // ACTION and unknown CTCPs: stay silent diff --git a/src/engine/mod.rs b/src/engine/mod.rs index e9d2cb9..ff81721 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -770,7 +770,9 @@ impl Engine { self.network.clear_account(uid); self.emit_irc(NetAction::Metadata { target: uid.to_string(), key: "accountname".to_string(), value: String::new() }); if let Some(ns) = self.nick_service.clone() { - let lang = self.lang_for_uid(uid); + // `clear_account(uid)` above already dropped the uid→account map, so + // resolve the language from the account name, not the (now-anonymous) uid. + let lang = self.lang_for_account(account); let reason = echo_api::render(&lang, reason, &[]); let text = echo_api::render(&lang, "Your account \x02{account}\x02 {reason}. You have been logged out.", &[("account", account.to_string()), ("reason", reason)]); self.emit_irc(NetAction::Notice { from: ns, to: uid.to_string(), text }); @@ -964,7 +966,10 @@ impl Engine { // keeps the running configuration untouched (validate-or-keep, like an ircd // rehash). Server identity (name/sid/protocol/uplink), service umodes and the // keycard endpoint are NOT reloadable — they need a restart. - pub fn rehash(&mut self, requester: &str, agent: &str) -> Vec { + // Reload config.toml and apply the reloadable settings live. Returns whether + // the new config actually loaded (a parse error keeps the running config) plus + // the notices to deliver to the requester and the staff log channel. + pub fn rehash(&mut self, requester: &str, agent: &str) -> (bool, Vec) { let path = self.config_path.clone(); // Who ran it, for the DebugServ log-channel line (account, else nick). let oper = self @@ -982,7 +987,7 @@ impl Engine { out.push(line); } out.push(notice(format!("REHASH failed: {e}. The running configuration is unchanged."))); - return out; + return (false, out); } }; let opers = cfg.opers(); @@ -1022,7 +1027,21 @@ impl Engine { echo_api::Priv::valid_names() ))); } - out + (true, out) + } + + // Rehash driven by the gRPC control CLI. There's no oper session to notice, so + // the per-requester replies are dropped, but the staff log-channel line is still + // delivered and the real load result is reported back to the caller. + pub fn control_rehash(&mut self) -> (bool, String) { + let (ok, actions) = self.rehash("gRPC control", ""); + for action in actions { + if matches!(action, NetAction::Privmsg { .. }) { + self.emit_irc(action); + } + } + let msg = if ok { "configuration reloaded" } else { "reload failed; running configuration unchanged" }; + (ok, msg.to_string()) } // A user arrived on (or changed to) a registered nick: if they aren't logged diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 6b05122..9b3b519 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -819,13 +819,21 @@ e.set_debugserv_uid("42SAAAAAO"); e.set_log_channel(Some("#services".to_string())); - let out = e.rehash("000AAAAAB", "42SAAAAAH"); + let (ok, out) = e.rehash("000AAAAAB", "42SAAAAAH"); + assert!(ok, "a valid config reloads successfully"); let feed = out.iter().find_map(|a| match a { NetAction::Privmsg { from, to, text } if from == "42SAAAAAO" && to == "#services" => Some(text.clone()), _ => None, }).expect("DebugServ announces the reload in the log channel"); assert!(feed.contains("reloaded the configuration"), "{feed}"); assert!(feed.contains("#svc"), "the reloaded services channel is applied and shown: {feed}"); + + // A parse error must report failure (not a false "reloaded") so the control + // CLI shows ✗, and must keep the running config. + std::fs::write(&cfgpath, "this is not = valid toml [[[").unwrap(); + let (ok, out) = e.rehash("000AAAAAB", "42SAAAAAH"); + assert!(!ok, "a broken config reports failure"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("REHASH failed"))), "{out:?}"); let _ = std::fs::remove_file(&cfgpath); } diff --git a/src/grpc.rs b/src/grpc.rs index 00001fa..a774f7d 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -432,11 +432,12 @@ impl Admin for AdminService { async fn rehash(&self, req: Request) -> Result, Status> { authorize(&req, &self.token)?; - // Same reload the OperServ REHASH path runs; the returned staff-feed notice - // has no oper to reach over gRPC, so it's dropped (the reload is logged). - let _ = self.engine.lock().await.rehash("gRPC control", ""); - tracing::info!("configuration reloaded via gRPC control"); - Ok(Response::new(AdminResponse { ok: true, message: "configuration reloaded".to_string() })) + // Same reload the OperServ REHASH path runs. control_rehash applies the + // config, delivers the staff-log announcement, and reports whether the new + // config actually loaded (a parse error keeps the running config). + let (ok, message) = self.engine.lock().await.control_rehash(); + tracing::info!(ok, "configuration reload requested via gRPC control"); + Ok(Response::new(AdminResponse { ok, message })) } } diff --git a/src/link.rs b/src/link.rs index 6f74014..0cb14f2 100644 --- a/src/link.rs +++ b/src/link.rs @@ -186,7 +186,7 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: // OperServ REHASH: re-read config.toml and apply the // reloadable settings live, reporting back to the oper. NetAction::Rehash { requester, agent } => { - engine.lock().await.rehash(&requester, &agent) + engine.lock().await.rehash(&requester, &agent).1 } action => vec![action], };