move Logfilter to the Logmessage module, change MessageHandler to a proper trait

This commit is contained in:
Chris Beck
2025-12-05 11:20:58 -07:00
parent 82ed77a098
commit a7cd3bdcde
11 changed files with 270 additions and 162 deletions
Generated
+2
View File
@@ -1645,6 +1645,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
name = "signal-gateway"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"chrono-tz",
"conf",
@@ -1672,6 +1673,7 @@ dependencies = [
name = "signal-gateway-bin"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"conf",
"conf-extra",
+1
View File
@@ -18,6 +18,7 @@ rustls-tls = ["signal-gateway/rustls-tls"]
[dependencies]
signal-gateway = { path = "../signal-gateway" }
async-trait = { workspace = true }
chrono = { workspace = true }
conf = { workspace = true }
conf-extra = { workspace = true }
+42 -31
View File
@@ -3,8 +3,11 @@
//! This module handles admin messages not handled by the gateway by making an HTTP POST request
//! with the message as the body, and returning the response body as the reply.
use async_trait::async_trait;
use conf::Conf;
use signal_gateway::{AdminMessageResponse, MessageHandler, MessageHandlerResult};
use signal_gateway::{
AdminMessageResponse, Context, MessageHandler, MessageHandlerResult, VerifiedSignalMessage,
};
use std::time::Duration;
/// Configuration for the admin HTTP client
@@ -19,46 +22,54 @@ pub struct AdminHttpConfig {
}
impl AdminHttpConfig {
/// Create a message handler function from this config.
/// Create a message handler from this config.
///
/// The returned handler makes an HTTP POST request to the configured URL
/// with the message as the body, and returns the response body.
pub fn into_handler(self) -> MessageHandler {
pub fn into_handler(self) -> Box<dyn MessageHandler> {
let client = reqwest::Client::builder()
.timeout(self.timeout)
.build()
.expect("Failed to build HTTP client");
Box::new(move |message: String| {
let client = client.clone();
let url = self.url.clone();
Box::pin(async move { handle_message(&client, &url, message).await })
Box::new(AdminHttpHandler {
client,
url: self.url,
})
}
}
/// Handle a message by POSTing it to the configured HTTP server
async fn handle_message(
client: &reqwest::Client,
url: &str,
message: String,
) -> MessageHandlerResult {
let response = client
.post(url)
.body(message)
.send()
.await
.map_err(|err| (502u16, format!("HTTP request failed: {err}").into()))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| (502u16, format!("Failed to read response body: {err}").into()))?;
if !status.is_success() {
return Err((status.as_u16(), body.into()));
}
Ok(AdminMessageResponse::new(body))
/// Message handler that forwards messages to an HTTP server.
struct AdminHttpHandler {
client: reqwest::Client,
url: String,
}
#[async_trait]
impl MessageHandler for AdminHttpHandler {
async fn handle_verified_signal_message(
&self,
msg: VerifiedSignalMessage,
_context: &dyn Context,
) -> MessageHandlerResult {
let response = self
.client
.post(&self.url)
.body(msg.message)
.send()
.await
.map_err(|err| (502u16, format!("HTTP request failed: {err}").into()))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| (502u16, format!("Failed to read response body: {err}").into()))?;
if !status.is_success() {
return Err((status.as_u16(), body.into()));
}
Ok(AdminMessageResponse::new(body))
}
}
+49 -37
View File
@@ -3,8 +3,11 @@
//! This module handles admin messages not handled by the gateway by opening a TCP connection,
//! writing the message terminated with CRLF, and reading the response until CRLF.
use async_trait::async_trait;
use conf::Conf;
use signal_gateway::{AdminMessageResponse, MessageHandler, MessageHandlerResult};
use signal_gateway::{
AdminMessageResponse, Context, MessageHandler, MessageHandlerResult, VerifiedSignalMessage,
};
use std::time::Duration;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
@@ -24,46 +27,55 @@ pub struct AdminNetcatConfig {
}
impl AdminNetcatConfig {
/// Create a message handler function from this config.
/// Create a message handler from this config.
///
/// The returned handler opens a TCP connection to the configured address,
/// writes the message terminated with CRLF, and reads the response until CRLF.
pub fn into_handler(self) -> MessageHandler {
Box::new(move |message: String| {
let config = self.clone();
Box::pin(async move { handle_message(&config, message).await })
})
pub fn into_handler(self) -> Box<dyn MessageHandler> {
Box::new(AdminNetcatHandler { config: self })
}
}
/// Handle a message by forwarding it to the configured TCP server
async fn handle_message(config: &AdminNetcatConfig, message: String) -> MessageHandlerResult {
// Connect to server
let mut stream = timeout(config.timeout, TcpStream::connect(&config.tcp_addr))
.await
.map_err(|_| (504u16, "connecting: timeout".into()))?
.map_err(|err| (502u16, format!("connecting: {err}").into()))?;
// Write message with CRLF terminator
let message = format!("{message}\r\n");
timeout(config.timeout, stream.write_all(message.as_bytes()))
.await
.map_err(|_| (504u16, "writing: timeout".into()))?
.map_err(|err| (502u16, format!("writing: {err}").into()))?;
// Read response until CR
let mut reader = BufReader::new(stream);
let mut buf = Vec::new();
timeout(config.timeout, reader.read_until(b'\r', &mut buf))
.await
.map_err(|_| (504u16, "reading: timeout".into()))?
.map_err(|err| (502u16, format!("reading: {err}").into()))?;
// Convert to string and trim the trailing CR
let text = std::str::from_utf8(&buf)
.map_err(|err| (502u16, format!("utf8: {err}").into()))?
.trim_end_matches(['\r', '\n'])
.to_owned();
Ok(AdminMessageResponse::new(text))
/// Message handler that forwards messages to a TCP server.
struct AdminNetcatHandler {
config: AdminNetcatConfig,
}
#[async_trait]
impl MessageHandler for AdminNetcatHandler {
async fn handle_verified_signal_message(
&self,
msg: VerifiedSignalMessage,
_context: &dyn Context,
) -> MessageHandlerResult {
// Connect to server
let mut stream =
timeout(self.config.timeout, TcpStream::connect(&self.config.tcp_addr))
.await
.map_err(|_| (504u16, "connecting: timeout".into()))?
.map_err(|err| (502u16, format!("connecting: {err}").into()))?;
// Write message with CRLF terminator
let message = format!("{}\r\n", msg.message);
timeout(self.config.timeout, stream.write_all(message.as_bytes()))
.await
.map_err(|_| (504u16, "writing: timeout".into()))?
.map_err(|err| (502u16, format!("writing: {err}").into()))?;
// Read response until CR
let mut reader = BufReader::new(stream);
let mut buf = Vec::new();
timeout(self.config.timeout, reader.read_until(b'\r', &mut buf))
.await
.map_err(|_| (504u16, "reading: timeout".into()))?
.map_err(|err| (502u16, format!("reading: {err}").into()))?;
// Convert to string and trim the trailing CR
let text = std::str::from_utf8(&buf)
.map_err(|err| (502u16, format!("utf8: {err}").into()))?
.trim_end_matches(['\r', '\n'])
.to_owned();
Ok(AdminMessageResponse::new(text))
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ use conf::{Conf, Subcommands};
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use hyper_util::server::conn::auto;
use signal_gateway::{Gateway, GatewayConfig, MessageHandler};
use signal_gateway::{Gateway, GatewayConfig};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
@@ -34,7 +34,7 @@ enum AdminHandlerCommand {
}
impl AdminHandlerCommand {
fn into_handler(self) -> MessageHandler {
fn into_handler(self) -> Box<dyn signal_gateway::MessageHandler> {
match self {
AdminHandlerCommand::Netcat(config) => config.into_handler(),
AdminHandlerCommand::Http(config) => config.into_handler(),
+1
View File
@@ -14,6 +14,7 @@ rustls-tls = ["prometheus-http-client/rustls-tls"]
[dependencies]
prometheus-http-client = { workspace = true, default-features = false }
async-trait = { workspace = true }
chrono = { workspace = true }
conf = { workspace = true }
conf-extra = { workspace = true }
+8 -46
View File
@@ -2,7 +2,7 @@ use super::circular_buffer::CircularBuffer;
use super::{AdminMessage, MultiRateLimiter, RateThreshold, SourceLocationRateLimiter};
use crate::{
human_duration::HumanTMinus,
log_message::{Level, LogMessage, Origin},
log_message::{Level, LogFilter, LogMessage, Origin},
};
use chrono::{TimeDelta, Utc};
use conf::Conf;
@@ -50,49 +50,11 @@ pub struct LogHandlerConfig {
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AlertRule {
#[serde(default)]
pub msg_contains: String,
#[serde(default)]
pub module_equals: String,
#[serde(default)]
pub file_equals: String,
#[serde(default)]
pub line_equals: String,
#[serde(flatten)]
pub filter: LogFilter,
pub threshold: RateThreshold,
}
impl AlertRule {
/// Check if a log message passes the filter defined by this rule
fn eval_filter(&self, log_msg: &LogMessage) -> bool {
if !self.msg_contains.is_empty() && !log_msg.msg.contains(&self.msg_contains) {
return false;
}
if !self.module_equals.is_empty() {
match log_msg.module_path.as_deref() {
Some(module) if module == self.module_equals.as_str() => {}
_ => return false,
}
}
if !self.file_equals.is_empty() {
match log_msg.file.as_deref() {
Some(file) if file == self.file_equals.as_str() => {}
_ => return false,
}
}
if !self.line_equals.is_empty() {
match log_msg.line.as_deref() {
Some(line) if line == self.line_equals.as_str() => {}
_ => return false,
}
}
true
}
}
/// The log handler takes log messages from a single origin and decides what
/// to do with them.
///
@@ -131,15 +93,15 @@ impl LogHandler {
let any_rule_uses_module = config
.alert_rate_limits
.iter()
.any(|r| !r.module_equals.is_empty());
.any(|r| r.filter.uses_module());
let any_rule_uses_file = config
.alert_rate_limits
.iter()
.any(|r| !r.file_equals.is_empty());
.any(|r| r.filter.uses_file());
let any_rule_uses_line = config
.alert_rate_limits
.iter()
.any(|r| !r.line_equals.is_empty());
.any(|r| r.filter.uses_line());
let rate_limiters = config
.alert_rate_limits
.iter()
@@ -320,8 +282,8 @@ impl LogHandler {
// Check each configured rule - track which rule suppressed the alert
// Note: we check all rules even if one already suppressed, to update all rate limiters
let mut suppressed_by_rule: Option<usize> = None;
for (idx, (filter, limiter)) in self.rate_limiters.iter().enumerate() {
if filter.eval_filter(log_msg) && !limiter.lock().await.evaluate(ts_sec) {
for (idx, (rule, limiter)) in self.rate_limiters.iter().enumerate() {
if rule.filter.matches(log_msg) && !limiter.lock().await.evaluate(ts_sec) {
suppressed_by_rule.get_or_insert(idx);
}
}
+12 -39
View File
@@ -6,7 +6,9 @@ use crate::{
Envelope, Identity, MessageTarget, RpcClient, RpcClientError, SignalMessage, connect_tcp,
},
log_message::{LogMessage, Origin},
message_handler::{AdminMessageResponse, MessageHandler, MessageHandlerResult},
message_handler::{
AdminMessageResponse, Context, MessageHandler, MessageHandlerResult, VerifiedSignalMessage,
},
prometheus::{Prometheus, PrometheusConfig},
};
use chrono::Utc;
@@ -181,14 +183,14 @@ pub struct Gateway {
/// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives.
log_handlers: RwLock<HashMap<Origin, LogHandler>>,
/// Handler for admin messages that don't start with `/`
message_handler: Option<MessageHandler>,
message_handler: Option<Box<dyn MessageHandler>>,
}
impl Gateway {
pub async fn new(
config: GatewayConfig,
token: CancellationToken,
message_handler: Option<MessageHandler>,
message_handler: Option<Box<dyn MessageHandler>>,
) -> Self {
let (admin_mq_tx, admin_mq_rx) = unbounded_channel();
@@ -469,7 +471,8 @@ impl Gateway {
self.handle_gateway_command(cmd).await
} else if let Some(handler) = &self.message_handler {
handler(data.message.clone()).await
let msg = VerifiedSignalMessage::new(data.message.clone(), data.timestamp);
handler.handle_verified_signal_message(msg, &GatewayContext).await
} else {
Err((501u16, "No message handler configured".into()))
}
@@ -828,6 +831,11 @@ impl Gateway {
}
}
/// Placeholder context for message handlers.
struct GatewayContext;
impl Context for GatewayContext {}
impl Drop for Gateway {
fn drop(&mut self) {
self.token.cancel();
@@ -922,39 +930,4 @@ mod tests {
assert!(parse_gateway_command("/").is_err());
assert!(parse_gateway_command("").is_err());
}
#[test]
fn test_origin_matches_filter() {
let origin = Origin {
app: "muad-dib".into(),
host: "tokyo-server".into(),
};
// Without @: matches if app OR host contains the string
assert!(origin.matches_filter("muad"));
assert!(origin.matches_filter("dib"));
assert!(origin.matches_filter("tokyo"));
assert!(origin.matches_filter("server"));
assert!(!origin.matches_filter("paris"));
// With @: app must contain first part AND host must contain second part
assert!(origin.matches_filter("muad@tokyo"));
assert!(origin.matches_filter("dib@server"));
assert!(origin.matches_filter("muad-dib@tokyo-server"));
assert!(!origin.matches_filter("muad@paris"));
assert!(!origin.matches_filter("other@tokyo"));
// Empty parts with @
assert!(origin.matches_filter("@tokyo")); // empty app filter matches any app
assert!(origin.matches_filter("muad@")); // empty host filter matches any host
assert!(origin.matches_filter("@")); // both empty, matches everything
// Edge case: filter matches the @ in the format but origin has no @
let origin2 = Origin {
app: "app".into(),
host: "host".into(),
};
assert!(origin2.matches_filter("app@host"));
assert!(!origin2.matches_filter("app@other"));
}
}
+4 -2
View File
@@ -9,5 +9,7 @@ pub(crate) mod prometheus;
pub(crate) mod transports;
pub use gateway::{Gateway, GatewayConfig};
pub use log_message::{Level, LogMessage, LogMessageBuilder};
pub use message_handler::{AdminMessageResponse, MessageHandler, MessageHandlerResult};
pub use log_message::{Level, LogFilter, LogMessage, LogMessageBuilder};
pub use message_handler::{
AdminMessageResponse, Context, MessageHandler, MessageHandlerResult, VerifiedSignalMessage,
};
+104
View File
@@ -1,5 +1,7 @@
//! Log message schema used by this crate
use serde::Deserialize;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Level {
@@ -171,3 +173,105 @@ impl Origin {
}
}
}
/// Filter criteria for matching log messages
#[derive(Clone, Debug, Default, Deserialize)]
pub struct LogFilter {
#[serde(default)]
pub msg_contains: String,
#[serde(default)]
pub module_equals: String,
#[serde(default)]
pub file_equals: String,
#[serde(default)]
pub line_equals: String,
}
impl LogFilter {
/// Check if a log message matches this filter.
///
/// Returns true if all non-empty filter fields match the log message.
pub fn matches(&self, log_msg: &LogMessage) -> bool {
if !self.msg_contains.is_empty() && !log_msg.msg.contains(&self.msg_contains) {
return false;
}
if !self.module_equals.is_empty() {
match log_msg.module_path.as_deref() {
Some(module) if module == self.module_equals.as_str() => {}
_ => return false,
}
}
if !self.file_equals.is_empty() {
match log_msg.file.as_deref() {
Some(file) if file == self.file_equals.as_str() => {}
_ => return false,
}
}
if !self.line_equals.is_empty() {
match log_msg.line.as_deref() {
Some(line) if line == self.line_equals.as_str() => {}
_ => return false,
}
}
true
}
/// Returns true if this filter uses the module field
pub fn uses_module(&self) -> bool {
!self.module_equals.is_empty()
}
/// Returns true if this filter uses the file field
pub fn uses_file(&self) -> bool {
!self.file_equals.is_empty()
}
/// Returns true if this filter uses the line field
pub fn uses_line(&self) -> bool {
!self.line_equals.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_origin_matches_filter() {
let origin = Origin {
app: "muad-dib".into(),
host: "tokyo-server".into(),
};
// Without @: matches if app OR host contains the string
assert!(origin.matches_filter("muad"));
assert!(origin.matches_filter("dib"));
assert!(origin.matches_filter("tokyo"));
assert!(origin.matches_filter("server"));
assert!(!origin.matches_filter("paris"));
// With @: app must contain first part AND host must contain second part
assert!(origin.matches_filter("muad@tokyo"));
assert!(origin.matches_filter("dib@server"));
assert!(origin.matches_filter("muad-dib@tokyo-server"));
assert!(!origin.matches_filter("muad@paris"));
assert!(!origin.matches_filter("other@tokyo"));
// Empty parts with @
assert!(origin.matches_filter("@tokyo")); // empty app filter matches any app
assert!(origin.matches_filter("muad@")); // empty host filter matches any host
assert!(origin.matches_filter("@")); // both empty, matches everything
// Edge case: filter matches the @ in the format but origin has no @
let origin2 = Origin {
app: "app".into(),
host: "host".into(),
};
assert!(origin2.matches_filter("app@host"));
assert!(!origin2.matches_filter("app@other"));
}
}
+45 -5
View File
@@ -1,6 +1,37 @@
//! Message handler types for admin messages not handled by the gateway.
use std::{error::Error, future::Future, path::PathBuf, pin::Pin};
use async_trait::async_trait;
use std::{error::Error, path::PathBuf};
/// A verified Signal message from an admin.
///
/// This struct contains the message content and metadata for a message
/// that has been verified as coming from a trusted admin.
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct VerifiedSignalMessage {
/// The text content of the message.
pub message: String,
/// The timestamp of the message (milliseconds since Unix epoch).
pub timestamp: u64,
}
impl VerifiedSignalMessage {
/// Create a new verified signal message.
pub fn new(message: impl Into<String>, timestamp: u64) -> Self {
Self {
message: message.into(),
timestamp,
}
}
}
/// Context for message handler operations.
///
/// This trait provides access to gateway functionality that message handlers
/// may need. Currently empty, but reserved for future expansion.
#[async_trait]
pub trait Context: Send + Sync {}
/// Response to an admin message.
#[non_exhaustive]
@@ -77,7 +108,16 @@ impl AdminMessageResponseBuilder {
/// Result type for message handler responses.
pub type MessageHandlerResult = Result<AdminMessageResponse, (u16, Box<dyn Error + Send + Sync>)>;
/// Handler function for admin messages that don't start with `/`.
/// Takes the message text and returns a response.
pub type MessageHandler =
Box<dyn Fn(String) -> Pin<Box<dyn Future<Output = MessageHandlerResult> + Send>> + Send + Sync>;
/// Handler for admin messages that don't start with `/`.
#[async_trait]
pub trait MessageHandler: Send + Sync {
/// Handle a verified Signal message from an admin.
///
/// This is called for admin messages that don't start with `/` (which are
/// handled as gateway commands).
async fn handle_verified_signal_message(
&self,
msg: VerifiedSignalMessage,
context: &dyn Context,
) -> MessageHandlerResult;
}