initial commit

This commit is contained in:
Chris Beck
2025-12-04 16:34:31 -07:00
commit c44b515ce0
26 changed files with 6650 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold", "-C", "target-cpu=skylake", '--cfg=curve25519_dalek_backend="simd"', '--cfg=feature="precomputed-tables"']
+1
View File
@@ -0,0 +1 @@
**/target
Generated
+2896
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
[workspace]
resolver = "2"
members = [
"prom-client",
"signal-gateway",
]
[workspace.package]
edition = "2024"
[profile.release]
# line-tables-only: keeps file/line info for backtraces, but strips variable/type debug info
# This significantly reduces binary size while preserving useful stack traces
debug = "line-tables-only"
# LTO eliminates dead code across crates, reducing binary size esp. for the AWS crates
# "thin" is faster than "fat" LTO while still providing most benefits
lto = "thin"
[workspace.lints.clippy]
assigning_clones = "allow"
result_large_err = "allow"
[workspace.dependencies]
prom-client = { path = "prom-client" }
async-trait = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
circular-buffer = "1.2"
conf = "0.4"
conf-extra = "0.1"
displaydoc = "0.2"
dotenvy = "0.15"
futures-util = "0.3"
http-body-util = "0.1"
humantime = "2"
hyper = { version = "1.7", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto"] }
jsonrpsee = { version = "0.26", features = ["macros", "async-client"] }
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "ttf", "datetime", "area_series", "line_series", "full_palette"] }
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rust_decimal = { version = "1", features = ["serde-with-str"] }
rustls = "0.23"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
syslog_rfc5424 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7", features = ["codec"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
walkdir = "2"
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "prom-client"
version = "0.1.0"
edition.workspace = true
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
chrono = { workspace = true }
displaydoc = { workspace = true }
reqwest = { workspace = true }
rust_decimal = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
plotters = { workspace = true, optional = true }
[dev-dependencies]
conf = { workspace = true }
conf-extra = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing-subscriber = { workspace = true }
[features]
default = ["plot"]
plot = ["dep:plotters"]
+70
View File
@@ -0,0 +1,70 @@
use super::QueryRangeRequest;
use chrono::{DateTime, TimeDelta, Utc};
use std::{ops::Range, time::Duration};
pub struct QueryRangeRequestBuilder {
query: String,
range: Option<Range<DateTime<Utc>>>,
step: Option<Duration>,
count: usize,
}
impl QueryRangeRequestBuilder {
pub fn new(query: String) -> Self {
Self {
query,
range: None,
step: None,
count: 256,
}
}
pub fn range(mut self, range: Range<DateTime<Utc>>) -> Self {
if self.range.is_some() {
panic!("already set range: {:?}", self.range);
}
self.range = Some(range);
self
}
pub fn since(mut self, time: Duration) -> Self {
if self.range.is_some() {
panic!("already set range: {:?}", self.range);
}
let end = Utc::now();
let start = end - TimeDelta::from_std(time).unwrap();
self.range = Some(start..end);
self
}
pub fn step(mut self, step: Duration) -> Self {
if self.step.is_some() {
panic!("already set step: {:?}", self.step);
}
self.step = Some(step);
self
}
pub fn count(mut self, count: usize) -> Self {
self.count = count;
self
}
pub fn build(self) -> QueryRangeRequest {
let query = self.query;
let range = self.range.unwrap();
let step = self.step.unwrap_or_else(|| {
let delta = range.end - range.start;
(delta / (self.count as i32)).to_std().unwrap()
});
QueryRangeRequest {
query,
start: range.start,
end: range.end,
step: step.as_secs_f64(),
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use displaydoc::Display;
use url::ParseError;
#[derive(Debug, Display)]
pub enum Error {
/// URL: {0}
Url(ParseError),
/// Reqwest: {0}
Reqwest(reqwest::Error),
/// API: {0}: {1}
API(String, String),
/// Unexpected Result Type: {0}
UnexpectedResultType(String),
/// Missing data on success response
MissingData,
}
impl From<reqwest::Error> for Error {
fn from(src: reqwest::Error) -> Self {
Self::Reqwest(src)
}
}
impl From<ParseError> for Error {
fn from(src: ParseError) -> Self {
Self::Url(src)
}
}
impl std::error::Error for Error {}
+78
View File
@@ -0,0 +1,78 @@
//! Helpers for extracting common labels from metrics before presenting them
use std::{
collections::BTreeMap,
fmt::{Debug, Display},
};
/// A set of labels extracted by ExtractLabels
pub type Labels = BTreeMap<String, String>;
/// Common labels extracted from a very generic sequence of metric labels (key value pairs)
pub struct ExtractLabels {
pub name: String,
pub common_labels: Labels,
pub specific_labels: Vec<Labels>,
}
impl ExtractLabels {
pub fn new<'a, I, KV, K, V>(src: I, skip_labels: &[String]) -> Self
where
I: Iterator<Item = &'a KV>,
KV: Clone + Debug + 'a,
K: Display + 'a,
V: Display + 'a,
&'a KV: IntoIterator<Item = (&'a K, &'a V)>,
{
// Common labels needs to be String -> Option<String>, because when we find a conflict,
// we need to poison that key (by putting None)
let mut common_labels = BTreeMap::<String, Option<String>>::default();
let mut specific_labels: Vec<Labels> = src
.map(|kv| {
let labels: Labels = kv
.into_iter()
.map(|(k, v)| {
let k = k.to_string();
let v = v.to_string();
if let Some(existing_val) = common_labels.get_mut(&k) {
// If the existing value is None, we already eliminated this label,
// so don't add it back.
if let Some(common_val) = existing_val
&& common_val != &v
{
*existing_val = None;
}
} else {
common_labels.insert(k.clone(), Some(v.clone()));
}
(k, v)
})
.collect();
labels
})
.collect();
let mut common_labels = common_labels
.into_iter()
.filter_map(|(k, maybe_v)| maybe_v.map(|v| (k, v)))
.collect::<Labels>();
for sl in skip_labels {
common_labels.remove(sl);
}
for sp in &mut specific_labels {
for sl in skip_labels {
sp.remove(sl);
}
for cl in common_labels.keys() {
sp.remove(cl);
}
}
let name = common_labels.remove("__name__").unwrap_or_default();
ExtractLabels {
name,
common_labels,
specific_labels,
}
}
}
+140
View File
@@ -0,0 +1,140 @@
//! API for getting time series data from prometheus
//!
//! To use it, instantiate one of the request objects,
//! e.g. QueryRequest or QueryRangeRequest. When it's helpful a builder is provided.
//!
//! Then use `PromRequest` trait and call `send` or `send_with_client`.
//! This takes the prometheus url, and optionally a reqwest client to use.
//!
//! On success, the result is `PromData`. One would usually call `into_matrix()?`
//! or `into_vector()?` as expected for the request that is made.
//!
//! In prometheus, metric labels are just a set of key-value pairs. However,
//! if you are expecting certain structure, you may use any KV object that implements
//! `serde::Deserialize` in the `PromData` that results from the call.
use chrono::{DateTime, Utc};
use serde::{Serialize, de::DeserializeOwned};
use std::fmt::Debug;
mod builders;
pub use builders::QueryRangeRequestBuilder;
mod error;
pub use error::Error;
mod labels;
pub use labels::{ExtractLabels, Labels};
mod messages;
use messages::PromResponse;
pub use messages::{
AlertInfo, AlertStatus, AlertsResponse, MetricTimeseries, MetricValue, PromData,
};
#[cfg(feature = "plot")]
pub mod plot;
mod traits;
pub use traits::PromRequest;
/// Query parameters for /api/v1/query prometheus request
#[derive(Clone, Debug, Serialize)]
pub struct QueryRequest {
pub query: String,
pub time: Option<DateTime<Utc>>,
}
impl PromRequest for QueryRequest {
const PATH: &str = "/api/v1/query";
type Output<KV: Clone + Debug + DeserializeOwned> = PromData<KV>;
}
/// Query parameters for /api/v1/query_range prometheus request
/// Use builder to populate it
#[derive(Clone, Debug, Serialize)]
pub struct QueryRangeRequest {
query: String,
start: DateTime<Utc>,
end: DateTime<Utc>,
step: f64,
}
impl QueryRangeRequest {
/// Get builder for query range request with given query
pub fn builder(query: impl Into<String>) -> QueryRangeRequestBuilder {
QueryRangeRequestBuilder::new(query.into())
}
}
impl PromRequest for QueryRangeRequest {
const PATH: &str = "/api/v1/query_range";
type Output<KV: Clone + Debug + DeserializeOwned> = PromData<KV>;
}
/// Query parameters for /api/v1/series prometheus request
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct SeriesRequest {
pub matches: MatchList,
}
impl PromRequest for SeriesRequest {
const PATH: &str = "/api/v1/series";
type Output<KV: Clone + Debug + DeserializeOwned> = Vec<KV>;
}
/// Query parameters for /api/v1/labels prometheus request
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct LabelsRequest {
pub matches: MatchList,
}
impl PromRequest for LabelsRequest {
const PATH: &str = "/api/v1/labels";
type Output<KV: Clone + Debug + DeserializeOwned> = Vec<String>;
}
/// Query parameters for /api/v1/alerts prometheus request
#[derive(Clone, Debug, Serialize)]
pub struct AlertsRequest {}
impl PromRequest for AlertsRequest {
const PATH: &str = "/api/v1/alerts";
type Output<KV: Clone + Debug + DeserializeOwned> = AlertsResponse<KV>;
}
/// Represents a sequence of match[]=...,match[]=...
/// query parameters required by parts of prometheus api
#[derive(Clone, Debug)]
pub struct MatchList(Vec<String>);
impl Serialize for MatchList {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for v in &self.0 {
map.serialize_entry("match[]", v)?;
}
map.end()
}
}
impl From<Vec<String>> for MatchList {
fn from(src: Vec<String>) -> Self {
Self(src)
}
}
impl FromIterator<String> for MatchList {
fn from_iter<T>(iter: T) -> Self
where
T: IntoIterator<Item = String>,
{
Self(iter.into_iter().collect())
}
}
+159
View File
@@ -0,0 +1,159 @@
// The prometheus http interface (at port 9090) renders graphs in JS and gets the raw data using API requests like this:
//
// GET http://localhost:9090/api/v1/query_range?query=tick_time{quantile="0.99"}&step=14&start=1762534433.802&end=1762538033.802
//
// Response is:
//
// {
// status: "success"
// data: {
// resultType: "matrix",
// result: [
// {
// metric: { __name__: "tick_time", instance: "x.y.z.w", job: "ec2", .. },
// values: [
// [ 1762534433.802, "1.8974293514080933" ],
// [ 1762534447.802, "2.029724353457351" ],
// ..
// ]
// }
// ]
// }
// }
//
// For more detail see:
// https://prometheus.io/docs/prometheus/latest/querying/api/
use crate::Error;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, de::DeserializeOwned};
use std::{collections::HashMap, fmt::Debug};
use tracing::warn;
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "KV: DeserializeOwned")]
pub struct MetricValue<KV = HashMap<String, String>>
where
KV: Clone + Debug,
{
pub metric: KV,
#[serde(default)]
pub value: Option<(f64, Decimal)>,
// TODO: Include histograms
}
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "KV: DeserializeOwned")]
pub struct MetricTimeseries<KV = HashMap<String, String>>
where
KV: Clone + Debug,
{
pub metric: KV,
#[serde(default)]
pub values: Vec<(f64, Decimal)>,
// TODO: Include histograms
}
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "KV: DeserializeOwned")]
#[serde(tag = "resultType", content = "result", rename_all = "camelCase")]
pub enum PromData<KV = HashMap<String, String>>
where
KV: Clone + Debug,
{
Matrix(Vec<MetricTimeseries<KV>>),
Vector(Vec<MetricValue<KV>>),
}
impl<KV> PromData<KV>
where
KV: Clone + Debug,
{
pub fn into_matrix(self) -> Result<Vec<MetricTimeseries<KV>>, Error> {
match self {
Self::Matrix(data) => Ok(data),
_ => Err(Error::UnexpectedResultType(format!("{self:?}"))),
}
}
pub fn into_vector(self) -> Result<Vec<MetricValue<KV>>, Error> {
match self {
Self::Vector(data) => Ok(data),
_ => Err(Error::UnexpectedResultType(format!("{self:?}"))),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum Status {
Success,
Error,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "T: DeserializeOwned", rename_all = "camelCase")]
pub struct PromResponse<T>
where
T: Clone + Debug,
{
pub status: Status,
#[serde(default)]
pub data: Option<T>,
#[serde(default)]
pub error_type: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub warnings: Vec<String>,
}
impl<T> PromResponse<T>
where
T: Clone + Debug,
{
pub fn into_result(self) -> Result<T, Error> {
for warning in self.warnings {
warn!("Prometheus API response: {warning}");
}
match self.status {
Status::Success => Ok(self.data.ok_or(Error::MissingData)?),
Status::Error => Err(Error::API(
self.error_type.unwrap_or_default(),
self.error.unwrap_or_default(),
)),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "KV: DeserializeOwned")]
pub struct AlertsResponse<KV = HashMap<String, String>>
where
KV: Clone + Debug,
{
pub alerts: Vec<AlertInfo<KV>>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(bound = "KV: DeserializeOwned", rename_all = "camelCase")]
pub struct AlertInfo<KV = HashMap<String, String>>
where
KV: Clone + Debug,
{
pub active_at: DateTime<Utc>,
pub annotations: KV,
pub labels: KV,
pub state: AlertStatus,
pub value: String,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AlertStatus {
Pending,
Firing,
Resolved,
}
+276
View File
@@ -0,0 +1,276 @@
use crate::{ExtractLabels, MetricTimeseries};
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use plotters::prelude::*;
use rust_decimal::prelude::ToPrimitive;
use std::{
borrow::Borrow,
error::Error,
fmt::{Debug, Display, Write},
ops::Range,
path::Path,
};
pub struct PlotStyle {
pub drawing_area: (u32, u32),
pub background: RGBAColor,
pub grid: RGBAColor,
pub axis: RGBAColor,
pub text_color: RGBAColor,
pub text_font: String,
pub text_size: u32,
pub caption_size: u32,
pub data_colors: Vec<RGBAColor>,
pub threshold_color: RGBAColor,
pub skip_labels: Vec<String>,
pub utc_offset_hours: i32,
/// Optional title override - used when prometheus aggregations remove __name__
pub title: Option<String>,
}
impl Default for PlotStyle {
fn default() -> Self {
Self {
drawing_area: (1920, 1200),
background: WHITE.into(),
grid: RGBAColor(100, 100, 100, 0.5),
axis: BLACK.into(),
text_color: BLACK.into(),
text_font: "sans-serif".into(),
text_size: 18,
caption_size: 36,
data_colors: [
GREEN,
BLUE,
full_palette::ORANGE,
YELLOW,
MAGENTA,
full_palette::TEAL,
full_palette::PURPLE,
]
.iter()
.cloned()
.map(Into::into)
.collect(),
threshold_color: RED.mix(0.2),
skip_labels: vec!["job".into(), "instance".into()],
utc_offset_hours: -7,
title: None,
}
}
}
impl PlotStyle {
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
}
pub enum PlotThreshold {
GreaterThan(f64),
LessThan(f64),
}
impl PlotStyle {
pub fn dark_mode(mut self) -> Self {
self.background = BLACK.into();
self.grid = RGBAColor(100, 100, 100, 0.5);
self.axis = WHITE.into();
self.text_color = WHITE.into();
self
}
/// Plot a collection of metric timeseries data from prometheus, and possibly a "threshold" defined in an alert.
/// Write the result to a path. The file extension of the path will determine the format, e.g. png, gif, etc.
pub fn plot_timeseries<KV, K, V>(
&self,
path: impl AsRef<Path>,
mts: &[MetricTimeseries<KV>],
plot_threshold: Option<PlotThreshold>,
) -> Result<(), Box<dyn Error>>
where
KV: Clone + Debug,
K: Display,
V: Display,
for<'a> &'a KV: IntoIterator<Item = (&'a K, &'a V)>,
{
// Prepare to plot by scanning the data, finding x and y bounds, common labels, etc.
let ExtractLabels {
name,
common_labels,
specific_labels,
} = ExtractLabels::new(mts.iter().map(|mts| &mts.metric), &self.skip_labels);
let PreparedPlot {
x_range,
y_range,
ts,
} = PreparedPlot::prepare(mts)?;
// Figure out the caption for formatting style for date-times
// Use title override if provided, otherwise use extracted metric name
// Only append common_labels if no title override (since title likely already has labels)
let mut caption = if let Some(title) = &self.title {
title.clone()
} else {
let mut c = name;
if !common_labels.is_empty() {
write!(&mut c, " {common_labels:?}")?;
}
c
};
// Format date-times differently depending on the range of date-times being displayed.
//
// If they are all on the same day, then omit the day, and put it in the caption instead
let timezone =
FixedOffset::east_opt(3600 * self.utc_offset_hours).ok_or("invalid timezone")?;
let start_date_naive = x_range.start.with_timezone(&timezone).date_naive();
let date_format_str =
if start_date_naive == x_range.end.with_timezone(&timezone).date_naive() {
write!(&mut caption, " {start_date_naive}")?;
"%H:%M:%S"
} else {
"%m/%d %H:%M:%S"
};
// Add timezone offset to the caption
write!(&mut caption, " UTC{o}", o = self.utc_offset_hours)?;
// Actually start writing the file
let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area();
root_area.fill(&self.background)?;
let mut ctx = ChartBuilder::on(&root_area)
.set_label_area_size(LabelAreaPosition::Left, 100)
.set_label_area_size(LabelAreaPosition::Bottom, 40)
.caption(
caption,
(self.text_font.as_str(), self.caption_size, &self.text_color)
.into_text_style(&root_area),
)
.build_cartesian_2d(x_range.clone(), y_range.clone())?;
let text_style =
(self.text_font.as_str(), self.text_size, &self.text_color).into_text_style(&root_area);
ctx.configure_mesh()
.light_line_style(self.grid) // Dark gray grid lines
.axis_style(self.axis) // White axis lines
.bold_line_style(self.axis) // White bold lines
.label_style(text_style.clone())
.x_label_formatter(&|x| {
x.with_timezone(&timezone)
.format(date_format_str)
.to_string()
})
.draw()?;
if let Some(threshold) = plot_threshold {
let (limit, baseline) = match threshold {
PlotThreshold::GreaterThan(limit) => (limit, y_range.end),
PlotThreshold::LessThan(limit) => (limit, y_range.start),
};
ctx.draw_series(AreaSeries::new(
[(x_range.start, limit), (x_range.end, limit)],
baseline,
self.threshold_color,
))?;
}
// Only show legend if there are multiple series (single series doesn't need a legend)
let show_legend = specific_labels.len() > 1;
for (idx, (mut metric, vals)) in specific_labels.into_iter().zip(ts.into_iter()).enumerate()
{
let color = &self.data_colors[idx % self.data_colors.len()];
let name = metric.remove("__name__").unwrap_or_default();
let label = format!("{name} {metric:?}");
let series = ctx.draw_series(LineSeries::new(vals, color))?;
if show_legend {
series
.label(label)
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
}
}
if show_legend {
ctx.configure_series_labels()
.position(SeriesLabelPosition::LowerLeft)
.border_style(self.axis)
.background_style(self.background.mix(0.8))
.label_font(text_style)
.draw()?;
}
// Signal any errors that occurred when writing the file
// https://github.com/plotters-rs/plotters?tab=readme-ov-file#faq-list
root_area.present()?;
Ok(())
}
}
struct PreparedPlot {
x_range: Range<DateTime<Utc>>,
y_range: Range<f64>,
ts: Vec<Vec<(DateTime<Utc>, f64)>>,
}
impl PreparedPlot {
fn prepare<KV>(data: &[impl Borrow<MetricTimeseries<KV>>]) -> Result<Self, &'static str>
where
KV: Clone + Debug,
{
let mut x_range = None;
let mut y_range = None;
let ts: Vec<Vec<(_, f64)>> = data
.iter()
.map(|mts| {
let mts = mts.borrow();
mts.values
.iter()
.filter_map(|(k, v)| {
let x = f64_to_datetime(k)?;
let y = v.to_f64()?;
extend_range(&mut x_range, &x);
extend_range(&mut y_range, &y);
Some((x, y))
})
.collect::<Vec<_>>()
})
.collect();
let x_range = x_range.ok_or("No data")?;
let y_range = y_range.ok_or("No data")?;
Ok(Self {
x_range,
y_range,
ts,
})
}
}
fn f64_to_datetime(t: &f64) -> Option<DateTime<Utc>> {
let seconds = t.trunc() as i64;
let nanoseconds = (t.fract() * 1_000_000_000.0) as u32;
Utc.timestamp_opt(seconds, nanoseconds).single()
}
fn extend_range<T: PartialOrd + Clone>(range: &mut Option<Range<T>>, val: &T) {
if let Some(range) = range.as_mut() {
if range.start > *val {
range.start = val.clone();
} else if range.end < *val {
range.end = val.clone();
}
} else {
*range = Some(val.clone()..val.clone())
}
}
+34
View File
@@ -0,0 +1,34 @@
use crate::{Error, PromResponse};
use reqwest::{Client, Url};
use serde::{Serialize, de::DeserializeOwned};
use std::fmt::Debug;
#[async_trait::async_trait]
pub trait PromRequest: Serialize {
const PATH: &str;
type Output<KV: Clone + Debug + DeserializeOwned>: Clone + Debug + DeserializeOwned;
async fn send<KV>(&self, host: &str) -> Result<Self::Output<KV>, Error>
where
KV: Clone + Debug + DeserializeOwned,
{
self.send_with_client(&Client::new(), host).await
}
async fn send_with_client<KV>(
&self,
client: &Client,
host: &str,
) -> Result<Self::Output<KV>, Error>
where
KV: Clone + Debug + DeserializeOwned,
{
let url = Url::parse(host)?.join(Self::PATH)?;
let resp: PromResponse<Self::Output<KV>> =
client.get(url).query(&self).send().await?.json().await?;
let data = resp.into_result()?;
Ok(data)
}
}
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "signal-gateway"
version = "0.1.0"
edition.workspace = true
[lints]
workspace = true
[dependencies]
prom-client = { workspace = true }
chrono = { workspace = true }
circular-buffer = { workspace = true }
conf = { workspace = true }
conf-extra = { workspace = true }
displaydoc = { workspace = true }
dotenvy = { workspace = true }
futures-util = { workspace = true }
http-body-util = { workspace = true }
humantime = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
jsonrpsee = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
rust_decimal = { workspace = true }
rustls = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
syslog_rfc5424 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
walkdir = { workspace = true }
+18
View File
@@ -0,0 +1,18 @@
use crate::gateway::GatewayConfig;
use conf::Conf;
use std::net::SocketAddr;
#[derive(Conf, Debug)]
pub struct Config {
/// If true, just validate config and don't start
#[conf(long)]
pub dry_run: bool,
/// Socket to listen for HTTP requests (GET /health, POST /alert)
#[conf(long, env, default_value = "0.0.0.0:8000")]
pub http_listen_addr: SocketAddr,
/// Socket to listen for UDP messages, in syslog RFC 5424 format
#[conf(long, env, default_value = "0.0.0.0:5424")]
pub udp_listen_addr: SocketAddr,
#[conf(flatten)]
pub gateway: GatewayConfig,
}
+387
View File
@@ -0,0 +1,387 @@
use super::{AdminMessage, MultiRateLimiter, Origin, RateThreshold, SourceLocationRateLimiter};
use crate::human_duration::HumanTMinus;
use chrono::{TimeDelta, Utc};
use circular_buffer::CircularBuffer;
use conf::Conf;
use serde::Deserialize;
use std::{fmt, time::Duration};
use syslog_rfc5424::{SyslogMessage, SyslogSeverity};
use tokio::sync::{Mutex, mpsc::UnboundedSender};
use tracing::{error, info, warn};
/// Reason why an alert was suppressed
enum SuppressionReason {
/// Suppressed by a configured alert rule (with 0-based rule index)
Rule(usize),
/// Suppressed by the source-location rate limiter
SourceLocation { file: String, line: String },
}
impl fmt::Display for SuppressionReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SuppressionReason::Rule(idx) => write!(f, "rule[{idx}]"),
SuppressionReason::SourceLocation { file, line } => {
write!(f, "source-location({file}:{line})")
}
}
}
}
/// Config options related to the log handler, and what log messages it chooses to alert on.
#[derive(Clone, Conf, Debug)]
pub struct LogHandlerConfig {
#[conf(long, env, value_parser = serde_json::from_str)]
pub alert_rate_limits: Vec<AlertRule>,
#[conf(long, env, default_value = "10m", value_parser = conf_extra::parse_duration)]
pub overall_alert_limit: Duration,
#[conf(long, env)]
pub format_module: bool,
#[conf(long, env)]
pub format_source_location: bool,
/// Structured data ID for tracing metadata (module, file, line) in syslog messages
#[conf(long, env, default_value = "tracing-meta@64700")]
pub sd_id: String,
}
/// Specifies both a rate limiting threshold, and criteria for the threshold to apply
#[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,
pub threshold: RateThreshold,
}
impl AlertRule {
/// Check if a syslog message passes the filter defined by this rule
fn eval_filter(&self, syslog_msg: &SyslogMessage, sd_id: &str) -> bool {
if !self.msg_contains.is_empty() && !syslog_msg.msg.contains(&self.msg_contains) {
return false;
}
if !self.module_equals.is_empty() {
match syslog_msg.sd.find_tuple(sd_id, "module") {
Some(module) if module == self.module_equals.as_str() => {}
_ => return false,
}
}
if !self.file_equals.is_empty() {
match syslog_msg.sd.find_tuple(sd_id, "file") {
Some(file) if file == self.file_equals.as_str() => {}
_ => return false,
}
}
if !self.line_equals.is_empty() {
match syslog_msg.sd.find_tuple(sd_id, "line") {
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.
///
/// 1. Store them in a small circular buffer
/// 2. If it is an error, and meets other criteria, trigger an alert,
/// i.e. send a message to admins containing this log and other recent logs.
/// 3. The maximum rate of alerts can also be configured.
///
/// Additionally, the log handler can format the buffer of recent logs into a string,
/// if requested.
///
/// Each origin (app + host pair) gets its own LogHandler instance, managed by the Gateway.
#[derive(Debug)]
pub struct LogHandler {
config: LogHandlerConfig,
admin_mq_tx: UnboundedSender<AdminMessage>,
syslog_buffer: Mutex<CircularBuffer<64, SyslogMessage>>,
rate_limiters: Vec<(AlertRule, Mutex<MultiRateLimiter>)>,
/// Rate limiter keyed by source location (file:line), so different error locations
/// can alert independently without suppressing each other.
overall_limiter: Mutex<SourceLocationRateLimiter>,
/// True if any configured rule uses the module structured data field.
any_rule_uses_module: bool,
/// True if any configured rule uses the file structured data field.
any_rule_uses_file: bool,
/// True if any configured rule uses the line structured data field.
any_rule_uses_line: bool,
}
/// Maximum entries in the source-location rate limiter before triggering cleanup
const OVERALL_LIMITER_MAX_ENTRIES: usize = 2000;
impl LogHandler {
/// Initialize a new log handler
pub fn new(config: LogHandlerConfig, admin_mq_tx: UnboundedSender<AdminMessage>) -> Self {
let any_rule_uses_module = config
.alert_rate_limits
.iter()
.any(|r| !r.module_equals.is_empty());
let any_rule_uses_file = config
.alert_rate_limits
.iter()
.any(|r| !r.file_equals.is_empty());
let any_rule_uses_line = config
.alert_rate_limits
.iter()
.any(|r| !r.line_equals.is_empty());
let rate_limiters = config
.alert_rate_limits
.iter()
.map(|rule| {
(
rule.clone(),
Mutex::new(MultiRateLimiter::from(rule.threshold)),
)
})
.collect::<Vec<_>>();
let overall_limiter = Mutex::new(SourceLocationRateLimiter::new(
config.overall_alert_limit,
OVERALL_LIMITER_MAX_ENTRIES,
));
Self {
config,
admin_mq_tx,
syslog_buffer: Default::default(),
rate_limiters,
overall_limiter,
any_rule_uses_module,
any_rule_uses_file,
any_rule_uses_line,
}
}
/// Format recent logs into a string
pub async fn format_logs(&self) -> String {
let lk = self.syslog_buffer.lock().await;
let mut text = format!("{} log messages (newest first):\n", lk.len());
// Calculate now once for consistent relative timestamps
let now = Utc::now().timestamp();
// Collect and reverse to show newest first
let messages: Vec<_> = lk.iter().collect();
for syslog_msg in messages.into_iter().rev() {
self.write_syslog_msg(&mut text, syslog_msg, now);
}
text
}
/// Consume a new syslog message from the given origin
pub async fn handle_syslog_message(&self, mut syslog_msg: SyslogMessage, origin: Origin) {
let suppression_reason = self.check_suppression(&mut syslog_msg).await;
if let Some(reason) = &suppression_reason
&& syslog_msg.severity <= SyslogSeverity::SEV_ERR
{
let sev = Self::severity_to_str(syslog_msg.severity);
info!("Suppressed {sev} ({reason}):\n{}", syslog_msg.msg);
}
// Record this new message.
// Then, if we should alert now, also format the whole buffer to a string,
// and then release the lock.
let formatted_text = {
let mut lk = self.syslog_buffer.lock().await;
lk.push_back(syslog_msg);
if suppression_reason.is_some() {
return;
}
let mut text = String::default();
// Calculate now once for consistent relative timestamps
let now = Utc::now().timestamp();
// Iterate in reverse (newest first) without copying
for syslog_msg in lk.iter().rev() {
self.write_syslog_msg(&mut text, syslog_msg, now);
}
lk.clear();
text
};
if let Err(_err) = self.admin_mq_tx.send(AdminMessage {
origin: Some(origin),
text: formatted_text,
attachment_paths: Default::default(),
summary: None,
}) {
error!("Could not send alert message, queue is closed");
}
}
// Convert SyslogSeverity to our own all-caps string that fits in 5 chars
fn severity_to_str(severity: SyslogSeverity) -> &'static str {
match severity {
SyslogSeverity::SEV_EMERG => "EMERG",
SyslogSeverity::SEV_ALERT => "ALERT",
SyslogSeverity::SEV_CRIT => "CRIT",
SyslogSeverity::SEV_ERR => "ERROR",
SyslogSeverity::SEV_WARNING => "WARN",
SyslogSeverity::SEV_NOTICE => "NOTE",
SyslogSeverity::SEV_INFO => "INFO",
SyslogSeverity::SEV_DEBUG => "DEBUG",
}
}
// Format a syslog message into a Writer, followed by \n, and using any config options to do so
fn write_syslog_msg(
&self,
mut writer: impl std::fmt::Write,
syslog_msg: &SyslogMessage,
now: i64,
) {
let sev = Self::severity_to_str(syslog_msg.severity);
let msg = &syslog_msg.msg;
// Format relative timestamp if available
let time_str = if let Some(ts) = syslog_msg.timestamp {
let diff_secs = now.saturating_sub(ts);
HumanTMinus(TimeDelta::seconds(diff_secs)).to_string()
} else {
"T-?".to_owned()
};
// Extract metadata from structured data if configured
let sd_id = &self.config.sd_id;
let mut metadata_parts = Vec::new();
if self.config.format_module
&& let Some(module) = syslog_msg.sd.find_tuple(sd_id, "module")
{
metadata_parts.push(module.to_string());
}
if self.config.format_source_location {
let file_opt = syslog_msg.sd.find_tuple(sd_id, "file");
let line_opt = syslog_msg.sd.find_tuple(sd_id, "line");
let location = if let Some(file) = file_opt {
// Strip /home/{username}/ prefix if present
let trimmed_file = strip_prefix_and_one_slash(file, "/home/");
// Strip .cargo/registry/src/{hash}/ if present
let trimmed_file = strip_prefix_and_one_slash(trimmed_file, ".cargo/registry/src/");
if let Some(line) = line_opt {
format!("{trimmed_file}:{line}")
} else {
format!("{trimmed_file}:?")
}
} else {
// No file present, use "?" even if line is present
"?".to_owned()
};
metadata_parts.push(location);
}
// Format: "ERROR T-10s [foo bar.rs:42]: message"
// Pad severity to 5 chars (left-aligned), time to 8 chars (right-aligned)
let result = if metadata_parts.is_empty() {
writeln!(writer, "{:<5} {:>8}: {}", sev, time_str, msg)
} else {
let metadata = metadata_parts.join(" ");
writeln!(writer, "{:<5} {:>8} [{}]: {}", sev, time_str, metadata, msg)
};
if let Err(err) = result {
error!("Couldn't write syslog message ({err}): {sev}: {msg}");
}
}
/// Check if an alert should be suppressed for a given error message.
///
/// Returns `None` if the alert should fire, or `Some(reason)` if suppressed.
async fn check_suppression(&self, syslog_msg: &mut SyslogMessage) -> Option<SuppressionReason> {
let ts_sec = *syslog_msg
.timestamp
.get_or_insert_with(|| Utc::now().timestamp());
let high_severity = syslog_msg.severity <= SyslogSeverity::SEV_ERR;
if !high_severity {
// Low severity messages are always "suppressed" (not alerted on)
// but we don't need to log a reason for this
return Some(SuppressionReason::Rule(usize::MAX));
}
let sd_id = &self.config.sd_id;
// Warn if rules expect structured data but the message doesn't have it
let has_module = syslog_msg.sd.find_tuple(sd_id, "module").is_some();
let has_file = syslog_msg.sd.find_tuple(sd_id, "file").is_some();
let has_line = syslog_msg.sd.find_tuple(sd_id, "line").is_some();
if (self.any_rule_uses_module && !has_module)
|| (self.any_rule_uses_file && !has_file)
|| (self.any_rule_uses_line && !has_line)
{
warn!(
"Error message missing structured data (sd_id={sd_id}), filtering rules may not work: {syslog_msg:#?}"
);
}
// 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(syslog_msg, sd_id) && !limiter.lock().await.evaluate(ts_sec) {
suppressed_by_rule.get_or_insert(idx);
}
}
if let Some(idx) = suppressed_by_rule {
return Some(SuppressionReason::Rule(idx));
}
// Extract source location for per-location rate limiting
let file = syslog_msg
.sd
.find_tuple(sd_id, "file")
.map_or("?", |s| s.as_str());
let line = syslog_msg
.sd
.find_tuple(sd_id, "line")
.map_or("?", |s| s.as_str());
if !self
.overall_limiter
.lock()
.await
.evaluate(file, line, ts_sec)
{
return Some(SuppressionReason::SourceLocation {
file: file.to_owned(),
line: line.to_owned(),
});
}
None // Alert should fire
}
}
// Strip a prefix, then find the first remaining slash and skip up to that as well.
fn strip_prefix_and_one_slash<'a>(target: &'a str, prefix: &str) -> &'a str {
let Some(target) = target.strip_prefix(prefix) else {
return target;
};
if let Some((_, after)) = target.split_once('/') {
after
} else {
target
}
}
+857
View File
@@ -0,0 +1,857 @@
use crate::{
http::{AlertMessage, Status},
jsonrpc::{Envelope, RpcClient, RpcClientError, SignalMessage, connect_tcp},
plotter::{Plotter, PlotterConfig},
};
use conf::{Conf, Subcommands};
use futures_util::FutureExt;
use http_body_util::BodyExt;
use hyper::{Method, Request, Response, StatusCode, body::Incoming};
use prom_client::{AlertStatus, ExtractLabels};
//use jsonrpsee::async_client::{Client as JsonRpcClient, Error as JsonRpcError};
use chrono::Utc;
use std::{collections::HashMap, error::Error, fmt::Write, net::SocketAddr, time::Duration};
use syslog_rfc5424::SyslogMessage;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
join,
net::TcpStream,
sync::{
Mutex, RwLock,
mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
},
time::timeout,
};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
mod log_handler;
use log_handler::{LogHandler, LogHandlerConfig};
mod rate_limiter;
use rate_limiter::{MultiRateLimiter, RateThreshold, SourceLocationRateLimiter};
#[derive(Conf, Debug)]
pub struct GatewayConfig {
#[conf(long, env, default_value = "127.0.0.1:7583")]
pub signal_cli_tcp_addr: SocketAddr,
#[conf(long, env)]
pub signal_account: String,
#[conf(long, env)]
pub cbmm_tcp_addr: String,
#[conf(long, env, default_value = "5s", value_parser = conf_extra::parse_duration)]
pub cbmm_timeout: Duration,
#[conf(repeat, long, env)]
pub admin_uuid: Vec<String>,
#[conf(flatten)]
pub plotter: Option<PlotterConfig>,
#[conf(flatten)]
pub log_handler: LogHandlerConfig,
}
/// Wrapper for parsing gateway commands
#[derive(Clone, Debug, Conf)]
struct GatewayCommandWrapper {
#[conf(subcommands)]
command: GatewayCommand,
}
/// Commands that can be sent to the gateway (prefixed with /)
#[derive(Clone, Debug, Subcommands)]
enum GatewayCommand {
/// Show recent log messages
#[conf(name = "log", alias = "LOG")]
Log {
/// Optional filter: show only origins where app or host contains this string
#[conf(pos)]
filter: Option<String>,
},
/// Query prometheus for current values
#[conf(name = "query", alias = "QUERY")]
Query {
/// PromQL query expression
#[conf(pos)]
query: String,
},
/// Plot a prometheus query over time
#[conf(name = "plot", alias = "PLOT")]
Plot {
/// PromQL query expression
#[conf(pos)]
query: String,
/// Duration to plot (e.g., 1h, 24h)
#[conf(long, short = 'd', default_value = "1h", value_parser = conf_extra::parse_duration)]
duration: Duration,
},
/// List series matching label patterns
#[conf(name = "series", alias = "SERIES")]
Series {
/// Label matchers (e.g., __name__=~".*requests.*")
#[conf(repeat, pos)]
matchers: Vec<String>,
},
/// List label names matching patterns
#[conf(name = "labels", alias = "LABELS")]
Labels {
/// Label matchers
#[conf(repeat, pos)]
matchers: Vec<String>,
},
/// Show current alerts from prometheus
#[conf(name = "alerts", alias = "ALERTS")]
Alerts,
}
/// Parse a gateway command from a string (with or without leading /)
fn parse_gateway_command(s: &str) -> Result<GatewayCommand, String> {
// Remove leading slash if present
let s = s.strip_prefix('/').unwrap_or(s).trim();
if s.is_empty() {
return Err("Empty command".to_string());
}
// Parse using Conf, treating the input as command line arguments
// Prepend a dummy program name since Conf expects argv[0]
let args = std::iter::once("gateway")
.chain(s.split_whitespace())
.collect::<Vec<_>>();
GatewayCommandWrapper::try_parse_from::<&str, &str, &str>(args, vec![])
.map(|wrapper| wrapper.command)
.map_err(|e| e.to_string())
}
/// Identifies the source of log messages (app name + host).
/// Used to separate log buffers and rate limiters per source.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Origin {
pub app: String,
pub host: String,
}
impl std::fmt::Display for Origin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.app, self.host)
}
}
impl From<&SyslogMessage> for Origin {
fn from(msg: &SyslogMessage) -> Self {
Self {
app: msg.appname.clone().unwrap_or_default(),
host: msg.hostname.clone().unwrap_or_default(),
}
}
}
impl Origin {
/// Check if this origin matches a filter string.
///
/// If the filter contains '@', it is split on the first '@':
/// - The part before '@' must be a substring of `app`
/// - The part after '@' must be a substring of `host`
///
/// If the filter does not contain '@', it matches if either `app` or `host`
/// contains the filter string.
pub fn matches_filter(&self, filter: &str) -> bool {
if let Some((app_filter, host_filter)) = filter.split_once('@') {
self.app.contains(app_filter) && self.host.contains(host_filter)
} else {
self.app.contains(filter) || self.host.contains(filter)
}
}
}
/// A message queued to be sent to all admins.
/// This is generally an alert message, which may have attached images.
#[derive(Clone, Debug, Default)]
struct AdminMessage {
/// The origin of the message (app + host), if from syslog
origin: Option<Origin>,
text: String,
attachment_paths: Vec<String>,
/// Short summary for logging (e.g., alert names for prometheus).
/// If None, the consumer will use a truncated slice of `text` for logging.
summary: Option<String>,
}
/// The gateway manages sending messages to signal-cli and receiving messages from signal-cli.
/// It maintains a queue of messages to be sent to all admins, generated by alerts etc.
/// It also subscribes to messages received from signal and processes them one-by-one, possibly
/// making TCP request to cbmm to handle them.
///
/// This is the only task that communicates directly with signal-cli, and by design it linearizes
/// all interaction, which prevents any possible races.
///
/// The gateway also maintains a buffer of at most 64 syslog messages that it has received.
/// If an error occurs, that message and the previous messages in the buffer are sent to signal admins,
/// and then the buffer is purged. This serves as a minimal log-aggregation and alerting system.
pub struct Gateway {
config: GatewayConfig,
admin_mq_tx: UnboundedSender<AdminMessage>,
admin_mq_rx: Mutex<UnboundedReceiver<AdminMessage>>,
token: CancellationToken,
plotter: Option<Plotter>,
/// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives.
log_handlers: RwLock<HashMap<Origin, LogHandler>>,
}
impl Gateway {
pub async fn new(config: GatewayConfig, token: CancellationToken) -> Self {
let (admin_mq_tx, admin_mq_rx) = unbounded_channel();
let plotter = config
.plotter
.as_ref()
.map(|plotter_config| Plotter::new(plotter_config.clone()));
Self {
config,
admin_mq_tx,
admin_mq_rx: Mutex::new(admin_mq_rx),
token,
plotter,
log_handlers: RwLock::new(HashMap::new()),
}
}
pub async fn run(&self) {
loop {
if self.token.is_cancelled() {
return;
}
match connect_tcp(&self.config.signal_cli_tcp_addr).await {
Err(err) => {
error!(
"Could not connect to signal_cli @ ({}): {err}",
self.config.signal_cli_tcp_addr
);
tokio::time::sleep(Duration::from_secs(5)).await;
}
Ok(client) => {
if let Err(err) = self.do_run(&client).await {
error!("Error with signal cli, reconnecting: {err}");
tokio::time::sleep(Duration::from_secs(5)).await;
} else {
continue;
}
}
}
}
}
async fn do_run(&self, signal_cli: &impl RpcClient) -> Result<(), RpcClientError> {
let mut admin_mq_rx = self
.admin_mq_rx
.try_lock()
.expect("Mutex should not be contended");
let mut signal_rx = signal_cli
.subscribe_receive(Some(self.config.signal_account.clone()))
.await?;
loop {
tokio::select! {
_ = self.token.cancelled() => {
info!("Stop requested");
return Ok(());
},
outbound_admin_msg = admin_mq_rx.recv() => {
if let Some(msg) = outbound_admin_msg {
// Log summary, or first 500 bytes of text if no summary provided
let summary = msg.summary.as_deref().unwrap_or_else(|| {
let len = msg.text.len().min(500);
&msg.text[..len]
});
info!("Sending alert: {summary}");
// Prepend origin line if present
let message = if let Some(origin) = &msg.origin {
format!("[{origin}]\n{}", msg.text)
} else {
msg.text
};
SignalMessage {
sender: self.config.signal_account.clone(),
recipient: self.config.admin_uuid.clone(),
message,
attachments: msg.attachment_paths,
}.send(signal_cli).await?;
} else {
warn!("admin_mq_rx is closed, halting service");
self.token.cancel();
return Ok(());
}
},
signal_msg = signal_rx.next() => {
match signal_msg {
None => {
info!("Signal Rx: stream closed");
return Ok(());
},
Some(Err(err)) => {
error!("Signal Rx: {err}");
return Err(RpcClientError::ParseError(err));
}
Some(Ok(msg)) => {
//info!("Signal Rx: {msg:?}");
if msg.envelope.data_message.is_none() {
debug!("Ignoring message which was not a data message: {msg:?}");
continue;
}
if !self.config.admin_uuid.contains(&msg.envelope.source_uuid) {
warn!("Ignoring message from non-admin: {msg:?}");
continue;
}
let (resp, _) = join!(self.handle_signal_admin_message(&msg.envelope),
msg.envelope.send_read_receipt(signal_cli, &self.config.signal_account).map(|result| {
if let Err(err) = result {
warn!("Couldn't send read receipt: {err}");
}
}));
let (message, attachments) = resp.unwrap_or_else(
|(code, msg)| {
let text = format!("{code}: {msg}");
error!("(cbmm) {text}");
(text, vec![])
}
);
SignalMessage {
sender: self.config.signal_account.clone(),
recipient: vec![msg.envelope.source_uuid.clone()],
message,
attachments,
}.send(signal_cli).await?;
}
}
}
}
}
}
// Returns Err in case of a timeout
// Returns Ok when success or error text is generated
async fn handle_signal_admin_message(
&self,
msg: &Envelope,
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> {
let data = msg.data_message.as_ref().unwrap();
// Admin messages starting with / are handled by gateway
// Other messages are forwarded to cbmm
if data.message.starts_with("/") {
// Parse the command using conf
let cmd = parse_gateway_command(&data.message).map_err(|err| (400, err.into()))?;
self.handle_gateway_command(cmd).await
} else {
// Connect to cbmm. Note that we could use a keep-alive strategy here maybe...
let mut cbmm_stream = timeout(
self.config.cbmm_timeout,
TcpStream::connect(&self.config.cbmm_tcp_addr),
)
.await
.map_err(format_err("connecting", 504))?
.map_err(format_err("connecting", 502))?;
timeout(
self.config.cbmm_timeout,
cbmm_stream.write_all(data.message.as_bytes()),
)
.await
.map_err(format_err("writing", 504))?
.map_err(format_err("writing", 502))?;
let _ = cbmm_stream.shutdown().await;
// Wrap as BufReader so that we can use "read_until" which simplifies things
let mut reader = BufReader::new(cbmm_stream);
let mut buf = vec![];
timeout(self.config.cbmm_timeout, reader.read_until(b'\r', &mut buf))
.await
.map_err(format_err("reading", 504))?
.map_err(format_err("reading", 502))?;
let s = str::from_utf8(&buf).map_err(format_err("utf8", 502))?;
let text = s.trim().to_owned();
Ok((text, vec![]))
}
}
// Handler function that processes incoming http requests (push's from alertmanager expected)
pub async fn handle_http_request(
&self,
req: Request<Incoming>,
) -> Result<Response<String>, String> {
info!(
"Received http request: {} {} (version: {:?})",
req.method(),
req.uri().path(),
req.version()
);
fn ok_resp() -> Response<String> {
Response::new("OK".into())
}
fn err_resp(code: StatusCode, text: impl Into<String>) -> Response<String> {
let mut resp = Response::new(text.into());
*resp.status_mut() = code;
resp
}
match (req.method(), req.uri().path()) {
(&Method::GET, "/") => Ok(ok_resp()),
(&Method::GET, "/health") => Ok(ok_resp()),
(&Method::POST, "/alert") => {
let v = req
.into_body()
.collect()
.await
.map_err(|err| format!("When reading body bytes: {err}"))?
.to_bytes()
.to_vec();
if let Err((code, msg)) = self.handle_post_alert(&v).await {
Ok(err_resp(code, msg))
} else {
Ok(ok_resp())
}
}
_ => Ok(err_resp(
StatusCode::NOT_FOUND,
format!("Not found '{} {}'", req.method(), req.uri().path()),
)),
}
}
async fn handle_gateway_command(
&self,
cmd: GatewayCommand,
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> {
match cmd {
GatewayCommand::Log { filter } => {
let handlers = self.log_handlers.read().await;
if handlers.is_empty() {
return Ok(("No log sources registered yet".to_string(), vec![]));
}
let mut text = String::new();
for (origin, handler) in handlers.iter() {
// Apply filter if present
if let Some(ref f) = filter
&& !origin.matches_filter(f)
{
continue;
}
writeln!(&mut text, "=== [{origin}] ===").unwrap();
text.push_str(&handler.format_logs().await);
text.push('\n');
}
if text.is_empty() {
return Ok(("No matching log sources".to_string(), vec![]));
}
Ok((text, vec![]))
}
GatewayCommand::Query { query } => {
let plotter = self
.plotter
.as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
match plotter.oneoff_query(query).await {
Ok((
ExtractLabels {
name,
common_labels,
specific_labels,
},
ts,
)) => {
let mut text = format!("{name} {common_labels:?}\n");
for (mut sl, maybe_val) in specific_labels.into_iter().zip(ts.into_iter()) {
let name = sl.remove("__name__").unwrap_or_default();
let label = format!("{name} {sl:?}");
// TODO: Include timestamp?
let val = maybe_val
.map(|(_time, val)| val.to_string())
.unwrap_or_else(|| "-".to_string());
if let Err(err) = writeln!(&mut text, "\t{label}\t\t{val}") {
return Err((500, err.into()));
}
}
Ok((text, vec![]))
}
Err(err) => Err((500, err)),
}
}
GatewayCommand::Plot { query, duration } => {
let plotter = self
.plotter
.as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
plotter.purge_old_plots();
match plotter.create_oneoff_plot(query.clone(), duration).await {
Ok(filename) => Ok((query, vec![filename])),
Err(err) => Err((500, err)),
}
}
GatewayCommand::Series { matchers } => {
let plotter = self
.plotter
.as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
match plotter.series(&matcher_refs).await {
Ok(data) => {
let mut text = data.iter().fold(String::default(), |mut buf, kv| {
writeln!(&mut buf, "{kv:?}").unwrap();
buf
});
if text.is_empty() {
text = "no matches".into();
}
Ok((text, vec![]))
}
Err(err) => Err((500, err)),
}
}
GatewayCommand::Labels { matchers } => {
let plotter = self
.plotter
.as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
match plotter.labels(&matcher_refs).await {
Ok(data) => {
let mut text = data.iter().fold(String::default(), |mut buf, l| {
writeln!(&mut buf, "{l}").unwrap();
buf
});
if text.is_empty() {
text = "no matches".into();
}
Ok((text, vec![]))
}
Err(err) => Err((500, err)),
}
}
GatewayCommand::Alerts => {
let plotter = self
.plotter
.as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
match plotter.alerts().await {
Ok(data) => {
let now = Utc::now();
let mut text = data.iter().fold(String::default(), |mut buf, alert| {
let symbol = match alert.state {
AlertStatus::Pending => "🟡",
AlertStatus::Firing => "🔴",
AlertStatus::Resolved => "🟢",
};
let since = {
let dur = (now - alert.active_at).to_std().unwrap_or_default();
// reduce precision to at most seconds
let mut secs = dur.as_secs();
// if the duration is more than an hour, then reduce precision to minutes
if secs > 3600 {
secs -= secs % 60;
}
humantime::format_duration(Duration::new(secs, 0))
};
let annotations = &alert.annotations;
let labels = &alert.labels;
writeln!(&mut buf, "{symbol} {since} {annotations:?} {labels:?}")
.unwrap();
buf
});
if text.is_empty() {
text = "no alerts".into();
}
Ok((text, vec![]))
}
Err(err) => Err((500, err)),
}
}
}
}
async fn handle_post_alert(&self, body_bytes: &[u8]) -> Result<(), (StatusCode, &'static str)> {
let body_text = str::from_utf8(body_bytes).map_err(|err| {
warn!("When reading body bytes: {err}");
(StatusCode::BAD_REQUEST, "Request body was not utf-8")
})?;
let alert_msg: AlertMessage = serde_json::from_str(body_text).map_err(|err| {
error!("Could not parse json: {err}:\n{body_text}");
(StatusCode::BAD_REQUEST, "Invalid Json")
})?;
let text = self
.format_alert_text(&alert_msg)
.unwrap_or_else(|err| format!("error formatting alert text: {err}:\n{alert_msg:#?}"));
let mut attachment_paths = vec![];
if let Some(plotter) = self.plotter.as_ref() {
plotter.purge_old_plots();
for alert in alert_msg.alerts.iter() {
match plotter.create_alert_plot(alert).await {
Ok(path) => {
attachment_paths.push(path);
}
Err(err) => {
error!("Could not format plot: {err} for {alert:#?}");
}
}
}
}
// Build summary: status sigils followed by alert names
let summary = alert_msg
.alerts
.iter()
.map(|alert| {
let symbol = match alert.status {
Status::Firing => "🔴",
Status::Resolved => "🟢",
};
let name = alert
.labels
.get("alertname")
.map(|s| s.as_str())
.unwrap_or("?");
format!("{symbol}{name}")
})
.collect::<Vec<_>>()
.join(" ");
self.admin_mq_tx
.send(AdminMessage {
origin: None, // Prometheus alerts don't have a syslog origin
text,
attachment_paths,
summary: Some(summary),
})
.map_err(|_err| {
error!("Could not send alert message, queue is closed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Can't send signal msg right now, queue is closed",
)
})
}
fn format_alert_text(&self, msg: &AlertMessage) -> Result<String, String> {
let mut text = "Alert:\n".to_owned();
let now = Utc::now();
for alert in msg.alerts.iter() {
let symbol = match alert.status {
Status::Firing => "🔴",
Status::Resolved => "🟢",
};
let since = {
let dur = (now - alert.starts_at).to_std().unwrap_or_default();
// reduce precision to at most seconds
let mut secs = dur.as_secs();
// if the duration is more than an hour, then reduce precision to minutes
if secs > 3600 {
secs -= secs % 60;
}
humantime::format_duration(Duration::new(secs, 0))
};
let name = alert
.annotations
.get("summary")
.or_else(|| alert.labels.get("alertname"))
.map(|s| s.as_str())
.unwrap_or("?");
let expr = match alert.parse_expr_from_generator_url() {
Ok(expr) => expr,
Err(err) => {
error!(
"Couldn't parse generator url {}: {err}",
alert.generator_url
);
String::default()
}
};
writeln!(&mut text, "{symbol}: ({since}) '{name}' {expr}")
.map_err(|err| format!("formatting error: {err}"))?;
}
Ok(text)
}
pub async fn handle_syslog_message(&self, syslog_msg: SyslogMessage) {
let origin = Origin::from(&syslog_msg);
// Try to get existing handler with read lock first
{
let handlers = self.log_handlers.read().await;
if let Some(handler) = handlers.get(&origin) {
handler.handle_syslog_message(syslog_msg, origin).await;
return;
}
}
// Handler doesn't exist, need to create one with write lock
let mut handlers = self.log_handlers.write().await;
// Double-check in case another task created it while we were waiting for the write lock
let handler = handlers.entry(origin.clone()).or_insert_with(|| {
info!("Creating new log handler for origin: {origin}");
LogHandler::new(self.config.log_handler.clone(), self.admin_mq_tx.clone())
});
handler.handle_syslog_message(syslog_msg, origin).await;
}
}
impl Drop for Gateway {
fn drop(&mut self) {
self.token.cancel();
}
}
// Returns a lambda that expresses an error as a (u16, String) with given context info
fn format_err<E: std::fmt::Display>(
context: &'static str,
code: u16,
) -> impl Fn(E) -> (u16, Box<dyn Error>) {
move |err: E| -> (u16, Box<dyn Error>) { (code, format!("{context}: {err}").into()) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_gateway_command() {
// Test log command without filter
let cmd = parse_gateway_command("/log").unwrap();
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
// Test log command with filter
let cmd = parse_gateway_command("/log myapp").unwrap();
if let GatewayCommand::Log { filter } = cmd {
assert_eq!(filter, Some("myapp".to_string()));
} else {
panic!("Expected Log command");
}
// Test uppercase log command
let cmd = parse_gateway_command("/LOG").unwrap();
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
// Test query command
let cmd = parse_gateway_command("/query test_metric").unwrap();
if let GatewayCommand::Query { query } = cmd {
assert_eq!(query, "test_metric");
} else {
panic!("Expected Query command");
}
// Test query command (uppercase)
let cmd = parse_gateway_command("/QUERY test_metric").unwrap();
if let GatewayCommand::Query { query } = cmd {
assert_eq!(query, "test_metric");
} else {
panic!("Expected Query command");
}
// Test plot command with default duration
let cmd = parse_gateway_command("/plot my_query").unwrap();
if let GatewayCommand::Plot { query, duration } = cmd {
assert_eq!(query, "my_query");
assert_eq!(duration, Duration::from_secs(60 * 60));
} else {
panic!("Expected Plot command");
}
// Test plot command with custom duration
let cmd = parse_gateway_command("/plot my_query -d 24h").unwrap();
if let GatewayCommand::Plot { query, duration } = cmd {
assert_eq!(query, "my_query");
assert_eq!(duration, Duration::from_secs(24 * 60 * 60));
} else {
panic!("Expected Plot command");
}
// Test series command with matchers
let cmd = parse_gateway_command("/series metric1 metric2").unwrap();
if let GatewayCommand::Series { matchers } = cmd {
assert_eq!(matchers, vec!["metric1", "metric2"]);
} else {
panic!("Expected Series command");
}
// Test labels command
let cmd = parse_gateway_command("/labels foo bar").unwrap();
if let GatewayCommand::Labels { matchers } = cmd {
assert_eq!(matchers, vec!["foo", "bar"]);
} else {
panic!("Expected Labels command");
}
// Test alerts command
let cmd = parse_gateway_command("/alerts").unwrap();
assert!(matches!(cmd, GatewayCommand::Alerts));
// Test alerts command (uppercase)
let cmd = parse_gateway_command("/ALERTS").unwrap();
assert!(matches!(cmd, GatewayCommand::Alerts));
// Test without leading slash
let cmd = parse_gateway_command("log").unwrap();
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
// Test empty command
assert!(parse_gateway_command("/").is_err());
assert!(parse_gateway_command("").is_err());
}
#[test]
fn test_origin_matches_filter() {
let origin = Origin {
app: "muad-dib".to_string(),
host: "tokyo-server".to_string(),
};
// 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".to_string(),
host: "host".to_string(),
};
assert!(origin2.matches_filter("app@host"));
assert!(!origin2.matches_filter("app@other"));
}
}
+288
View File
@@ -0,0 +1,288 @@
use serde::Deserialize;
use std::{
collections::HashMap,
str::FromStr,
sync::atomic::{AtomicI64, Ordering},
time::Duration,
};
/// Represents a rate threshold, expressed as a string in the format:
///
/// * `1 / 10s`
/// * `2 / 5m`
/// * `3 / 1h`
/// * `> 1 / 10s`
/// * `>= 2 / 10s`
///
/// When the comparator is omitted, it is treated as `>=`
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(try_from = "String")]
pub struct RateThreshold {
pub times: usize,
pub duration: Duration,
}
impl FromStr for RateThreshold {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((first, second)) = s.trim().split_once('/') else {
return Err("missing '/' character in rate threshold".into());
};
let duration = conf_extra::parse_duration(second.trim())?;
let first = first.trim();
let maybe_mid = first.as_bytes().iter().position(|b| b.is_ascii_digit());
let (comparator, num) = if let Some(mid) = maybe_mid {
first.split_at(mid)
} else {
("", first)
};
let is_greater_equal = match comparator.trim() {
">" => false,
">=" | "=>" | "" => true,
_ => return Err(format!("Unexpected comparator format: {comparator}")),
};
let num = num.trim();
let mut times: usize = num
.parse()
.map_err(|err| format!("invalid number {num}: {err}"))?;
if !is_greater_equal {
times += 1;
}
if times == 0 {
return Err("Invalid threshold, times must be > 0".into());
}
Ok(RateThreshold { times, duration })
}
}
impl TryFrom<String> for RateThreshold {
type Error = <RateThreshold as FromStr>::Err;
fn try_from(s: String) -> Result<Self, Self::Error> {
RateThreshold::from_str(&s)
}
}
/// A rate limiter containing a single counter, and a minimum time window for the next event to pass
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct SimpleRateLimiter {
last_timestamp: AtomicI64,
window: i64,
}
#[allow(dead_code)]
impl SimpleRateLimiter {
pub fn new(window: Duration) -> Self {
Self {
last_timestamp: Default::default(),
window: window.as_secs().try_into().unwrap(),
}
}
/// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp.
pub fn evaluate(&self, ts_sec: i64) -> bool {
let last_ts = self.last_timestamp.load(Ordering::SeqCst);
let rate_limited = ts_sec - last_ts < self.window;
if !rate_limited && ts_sec > last_ts {
// If this is called concurrently, guarantee that we keep going
// until the max value is stored at self.last_timestamp,
// so self.last_timestamp is "eventually" only monotonically increasing.
store_max(ts_sec, &self.last_timestamp);
}
!rate_limited
}
}
#[allow(dead_code)]
fn store_max(val: i64, at: &AtomicI64) {
let prev = at.swap(val, Ordering::SeqCst);
if prev > val {
store_max(prev, at)
}
}
/// A rate limiter that tracks alerts per source location (file:line).
///
/// This allows different error locations to alert independently, preventing one noisy
/// error from suppressing alerts from completely different code paths.
#[derive(Debug)]
pub struct SourceLocationRateLimiter {
/// Maps (file, line) -> last alert timestamp
last_timestamps: HashMap<(String, String), i64>,
/// The rate limiting window in seconds
window: i64,
/// Maximum entries before triggering cleanup
max_entries: usize,
}
impl SourceLocationRateLimiter {
pub fn new(window: Duration, max_entries: usize) -> Self {
Self {
last_timestamps: HashMap::new(),
window: window.as_secs().try_into().unwrap(),
max_entries,
}
}
/// Check if an error from this source location should trigger an alert.
///
/// Returns true if the alert should fire (not rate-limited), false if suppressed.
/// Updates the stored timestamp if the alert fires.
pub fn evaluate(&mut self, file: &str, line: &str, ts_sec: i64) -> bool {
let key = (file.to_owned(), line.to_owned());
if let Some(&last_ts) = self.last_timestamps.get(&key)
&& ts_sec - last_ts < self.window
{
return false; // Rate limited
}
// Alert should fire - update timestamp
self.last_timestamps.insert(key, ts_sec);
// Clean up if we've exceeded max entries
if self.last_timestamps.len() > self.max_entries {
self.cleanup(ts_sec);
}
true
}
/// Remove entries older than the window
fn cleanup(&mut self, now: i64) {
self.last_timestamps
.retain(|_, &mut ts| now - ts < self.window);
}
}
/// Implements rate-limiting criteria such as 'at least n in the last w seconds'
#[derive(Debug)]
pub struct MultiRateLimiter {
/// Records the last n events
timestamps: Vec<i64>,
/// Invariant: Always points to the oldest of the last n timestamps in the buffer
idx: usize,
/// The length of the window (in seconds)
window: i64,
}
impl MultiRateLimiter {
pub fn new(num: usize, window: Duration) -> Self {
Self {
idx: 0,
timestamps: vec![Default::default(); num],
window: window.as_secs().try_into().unwrap(),
}
}
/// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp.
///
/// Note: Assumes that new_timestamp is monotonically increasing, otherwise it might not work right.
pub fn evaluate(&mut self, new_timestamp: i64) -> bool {
let oldest = self.timestamps[self.idx];
if oldest >= new_timestamp {
return false;
}
self.timestamps[self.idx] = new_timestamp;
self.idx += 1;
self.idx %= self.timestamps.len();
let next_oldest = self.timestamps[self.idx];
// If the next oldest is within 'window' of the new timestamp,
// then all of the most recent n are. Otherwise, at most n-1 of the most recent are.
next_oldest + self.window >= new_timestamp
}
}
impl From<RateThreshold> for MultiRateLimiter {
fn from(src: RateThreshold) -> Self {
Self::new(src.times, src.duration)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rate_threshold() {
let threshold = RateThreshold::from_str("1/10s").unwrap();
assert_eq!(threshold.times, 1);
assert_eq!(threshold.duration, Duration::from_secs(10));
let threshold = RateThreshold::from_str("1 / 10s").unwrap();
assert_eq!(threshold.times, 1);
assert_eq!(threshold.duration, Duration::from_secs(10));
let threshold = RateThreshold::from_str("2 / 5m").unwrap();
assert_eq!(threshold.times, 2);
assert_eq!(threshold.duration, Duration::from_secs(300));
let threshold = RateThreshold::from_str("> 3 / 10m").unwrap();
assert_eq!(threshold.times, 4);
assert_eq!(threshold.duration, Duration::from_secs(600));
let threshold = RateThreshold::from_str(">=3/10m").unwrap();
assert_eq!(threshold.times, 3);
assert_eq!(threshold.duration, Duration::from_secs(600));
}
#[test]
fn source_location_rate_limiter_basic() {
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100);
// First alert from location A should pass
assert!(limiter.evaluate("file_a.rs", "10", 1000));
// Second alert from same location within window should be rate limited
assert!(!limiter.evaluate("file_a.rs", "10", 1100));
// Alert from different location should pass (independent rate limiting)
assert!(limiter.evaluate("file_b.rs", "20", 1100));
// Same location after window passes should alert again
assert!(limiter.evaluate("file_a.rs", "10", 1700)); // 1000 + 600 + 100
}
#[test]
fn source_location_rate_limiter_different_lines_same_file() {
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100);
// Different lines in same file should be independent
assert!(limiter.evaluate("file.rs", "10", 1000));
assert!(limiter.evaluate("file.rs", "20", 1000));
assert!(limiter.evaluate("file.rs", "30", 1000));
// Each should still be rate limited individually
assert!(!limiter.evaluate("file.rs", "10", 1100));
assert!(!limiter.evaluate("file.rs", "20", 1100));
}
#[test]
fn source_location_rate_limiter_cleanup() {
// Use small max_entries to trigger cleanup
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 3);
// Fill up the limiter
assert!(limiter.evaluate("file1.rs", "1", 1000));
assert!(limiter.evaluate("file2.rs", "2", 1000));
assert!(limiter.evaluate("file3.rs", "3", 1000));
assert_eq!(limiter.last_timestamps.len(), 3);
// Add one more, triggering cleanup - but all are fresh so none removed
assert!(limiter.evaluate("file4.rs", "4", 1000));
// Still have 4 after cleanup since none are old enough
assert_eq!(limiter.last_timestamps.len(), 4);
// Now add with a timestamp far in the future - old entries should be cleaned
assert!(limiter.evaluate("file5.rs", "5", 2000));
// Should have cleaned up entries from timestamp 1000 (older than 600 sec window)
assert_eq!(limiter.last_timestamps.len(), 1);
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Schema for the alertmanager http POST requests that are sent to us
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::Url;
pub type Timestamp = DateTime<Utc>;
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Resolved,
Firing,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AlertMessage {
// should be 4.0
pub version: String,
pub group_key: String,
pub status: Status,
pub receiver: String,
#[serde(default)]
pub group_labels: BTreeMap<String, String>,
#[serde(default)]
pub common_labels: BTreeMap<String, String>,
#[serde(default)]
pub common_annotations: BTreeMap<String, String>,
#[serde(alias = "externalURL")]
pub external_url: String,
pub alerts: Vec<Alert>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Alert {
pub status: Status,
#[serde(default)]
pub labels: BTreeMap<String, String>,
#[serde(default)]
pub annotations: BTreeMap<String, String>,
pub starts_at: Timestamp,
pub ends_at: Timestamp,
#[serde(alias = "generatorURL")]
pub generator_url: String,
pub fingerprint: String,
}
impl Alert {
pub fn parse_expr_from_generator_url(&self) -> Result<String, String> {
let url = Url::parse(&self.generator_url).map_err(|err| err.to_string())?;
for (k, v) in url.query_pairs() {
if k == "g0.expr" {
return Ok(v.into_owned());
}
}
Err("Couldn't find g0.expr".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alert_message_parsing() {
let text = r#"{"receiver":"notify-chris","status":"firing","alerts":[{"status":"firing","labels":{"alertname":"Low tick success rate","instance":"172.31.5.8:9000","job":"ec2"},"annotations":{"summary":"Low tick success rate"},"startsAt":"2025-11-07T04:21:46.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=rate%28tick_successes%5B5m%5D%29+%3C+0.9\u0026g0.tab=1","fingerprint":"543b6a7a3042ae2c"},{"status":"firing","labels":{"alertname":"Long tail tick times","instance":"172.31.5.8:9000","job":"ec2","quantile":"0.99"},"annotations":{"summary":"Long tail tick times"},"startsAt":"2025-11-07T04:50:01.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=tick_time%7Bquantile%3D%220.99%22%7D+%3E+0.8\u0026g0.tab=1","fingerprint":"97130d38ef0ff0a4"}],"groupLabels":{},"commonLabels":{"instance":"172.31.5.8:9000","job":"ec2"},"commonAnnotations":{},"externalURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9093","version":"4","groupKey":"{}:{}","truncatedAlerts":0}"#;
let msg: AlertMessage = serde_json::from_str(text).unwrap();
assert_eq!(&msg.receiver, "notify-chris");
assert_eq!(msg.alerts.len(), 2);
assert_eq!(
msg.alerts[0].annotations.get("summary").unwrap(),
"Low tick success rate"
);
let expr = msg.alerts[1].parse_expr_from_generator_url().unwrap();
assert_eq!(expr, r#"tick_time{quantile="0.99"} > 0.8"#);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Display wrapper for formatting chrono::TimeDelta as "T-duration"
use chrono::TimeDelta;
use std::{
fmt::{self, Display},
time::Duration,
};
/// A display wrapper that formats a chrono::TimeDelta as "T-duration" or "T+duration"
/// with coarse precision (truncated to minutes for durations > 1 hour).
#[derive(Clone, Copy, Debug)]
pub struct HumanTMinus(pub TimeDelta);
impl Display for HumanTMinus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (prefix, abs_duration) = if self.0.num_seconds() >= 0 {
("T-", self.0)
} else {
("T+", -self.0)
};
let std_dur = abs_duration.to_std().unwrap_or_default();
let mut secs = std_dur.as_secs();
// Reduce precision to minutes for durations > 1 hour
if secs > 3600 {
secs -= secs % 60;
}
let coarse_dur = Duration::new(secs, 0);
// Remove spaces for compact format (e.g., "T-1m30s" not "T-1m 30s")
let formatted = humantime::format_duration(coarse_dur)
.to_string()
.replace(' ', "");
write!(f, "{}{}", prefix, formatted)
}
}
impl From<TimeDelta> for HumanTMinus {
fn from(td: TimeDelta) -> Self {
HumanTMinus(td)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_human_t_minus_positive() {
assert_eq!(HumanTMinus(TimeDelta::seconds(30)).to_string(), "T-30s");
assert_eq!(HumanTMinus(TimeDelta::seconds(90)).to_string(), "T-1m30s");
assert_eq!(HumanTMinus(TimeDelta::hours(1)).to_string(), "T-1h");
// > 1 hour: truncate to minutes
assert_eq!(HumanTMinus(TimeDelta::seconds(3700)).to_string(), "T-1h1m");
}
#[test]
fn test_human_t_minus_negative() {
assert_eq!(HumanTMinus(TimeDelta::seconds(-30)).to_string(), "T+30s");
assert_eq!(HumanTMinus(TimeDelta::seconds(-3700)).to_string(), "T+1h1m");
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Logging initialization for signal-gateway
use std::env;
use tracing::info;
pub fn init_logging() {
// Install rustls crypto provider before any TLS connections are made.
// This is needed because we have both aws-lc-rs and ring in our dependency tree,
// and rustls 0.23 can't auto-detect which one to use when both are present.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
if env::var("RUST_LOG").is_err() {
unsafe {
env::set_var("RUST_LOG", "info");
}
}
// Build a default tracing subscriber, writing to STDERR
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_file(true)
.with_line_number(true)
.init();
// load dotenv file
match dotenvy::dotenv() {
Ok(path) => info!("Read dotenv file from: {}", path.display()),
Err(dotenvy::Error::Io(io_error)) => {
if matches!(io_error.kind(), std::io::ErrorKind::NotFound) {
info!("Couldn't find a dotenv file");
} else {
panic!("Io error when reading dot env file: {io_error}")
}
}
Err(err) => {
panic!("Error reading dotenv file: {err}")
}
}
}
+557
View File
@@ -0,0 +1,557 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
#![allow(clippy::too_many_arguments)]
use jsonrpsee::async_client::ClientBuilder;
use jsonrpsee::core::client::SubscriptionClientT;
use jsonrpsee::proc_macros::rpc;
use serde::Deserialize;
use serde_json::Value;
use tokio::net::ToSocketAddrs;
pub use jsonrpsee::core::ClientError as RpcClientError;
#[rpc(client)]
pub trait Rpc {
#[method(name = "addDevice", param_kind = map)]
async fn add_device(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "addStickerPack", param_kind = map)]
async fn add_sticker_pack(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "block", param_kind = map)]
fn block(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "deleteLocalAccountData", param_kind = map)]
fn delete_local_account_data(
&self,
account: Option<String>,
#[allow(non_snake_case)] ignoreRegistered: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAttachment", param_kind = map)]
fn get_attachment(
&self,
account: Option<String>,
id: String,
recipient: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAvatar", param_kind = map)]
fn get_avatar(
&self,
account: Option<String>,
contact: Option<String>,
profile: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getSticker", param_kind = map)]
fn get_sticker(
&self,
account: Option<String>,
#[allow(non_snake_case)] packId: String,
#[allow(non_snake_case)] stickerId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getUserStatus", param_kind = map)]
fn get_user_status(
&self,
account: Option<String>,
recipients: Vec<String>,
usernames: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "joinGroup", param_kind = map)]
fn join_group(&self, account: Option<String>, uri: String) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "finishChangeNumber", param_kind = map)]
fn finish_change_number(
&self,
account: Option<String>,
number: String,
verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "finishLink", param_kind = map)]
fn finish_link(
&self,
#[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)]
fn list_accounts(&self) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listContacts", param_kind = map)]
fn list_contacts(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>,
name: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)]
fn list_devices(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listGroups", param_kind = map)]
fn list_groups(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listIdentities", param_kind = map)]
fn list_identities(
&self,
account: Option<String>,
number: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listStickerPacks", param_kind = map)]
fn list_sticker_packs(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "quitGroup", param_kind = map)]
fn quit_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: String,
delete: bool,
admins: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "register", param_kind = map)]
fn register(
&self,
account: Option<String>,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)]
fn remove_contact(
&self,
account: Option<String>,
recipient: String,
forget: bool,
hide: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeDevice", param_kind = map)]
fn remove_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removePin", param_kind = map)]
fn remove_pin(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "remoteDelete", param_kind = map)]
fn remote_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] targetTimestamp: u64,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "send", param_kind = map)]
fn send(
&self,
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification(
&self,
account: Option<String>,
recipient: String,
receipt: String,
note: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReaction", param_kind = map)]
fn send_reaction(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
emoji: String,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
remove: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReceipt", param_kind = map)]
fn send_receipt(
&self,
account: Option<String>,
recipient: String,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendSyncRequest", param_kind = map)]
fn send_sync_request(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendTyping", param_kind = map)]
fn send_typing(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
stop: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendMessageRequestResponse", param_kind = map)]
fn send_message_request_response(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "setPin", param_kind = map)]
fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value, ErrorObjectOwned>;
#[method(name = "submitRateLimitChallenge", param_kind = map)]
fn submit_rate_limit_challenge(
&self,
account: Option<String>,
challenge: String,
captcha: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startChangeNumber", param_kind = map)]
fn start_change_number(
&self,
account: Option<String>,
number: String,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startLink", param_kind = map)]
fn start_link(&self, account: Option<String>) -> Result<JsonLink, ErrorObjectOwned>;
#[method(name = "trust", param_kind = map)]
fn trust(
&self,
account: Option<String>,
recipient: String,
#[allow(non_snake_case)] trustAllKnownKeys: bool,
#[allow(non_snake_case)] verifiedSafetyNumber: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unblock", param_kind = map)]
fn unblock(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unregister", param_kind = map)]
fn unregister(
&self,
account: Option<String>,
#[allow(non_snake_case)] deleteAccount: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "updateAccount", param_kind = map)]
fn update_account(
&self,
account: Option<String>,
deviceName: Option<String>,
unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)]
fn update_configuration(
&self,
account: Option<String>,
#[allow(non_snake_case)] readReceipts: Option<bool>,
#[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>,
#[allow(non_snake_case)] typingIndicators: Option<bool>,
#[allow(non_snake_case)] linkPreviews: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateContact", param_kind = map)]
fn update_contact(
&self,
account: Option<String>,
recipient: String,
name: Option<String>,
expiration: Option<u32>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]
fn update_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
name: Option<String>,
description: Option<String>,
avatar: Option<String>,
member: Vec<String>,
#[allow(non_snake_case)] removeMember: Vec<String>,
admin: Vec<String>,
#[allow(non_snake_case)] removeAdmin: Vec<String>,
ban: Vec<String>,
unban: Vec<String>,
#[allow(non_snake_case)] resetLink: bool,
#[allow(non_snake_case)] link: Option<String>,
#[allow(non_snake_case)] setPermissionAddMember: Option<String>,
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)]
fn update_profile(
&self,
account: Option<String>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
about: Option<String>,
#[allow(non_snake_case)] aboutEmoji: Option<String>,
#[allow(non_snake_case)] mobileCoinAddress: Option<String>,
avatar: Option<String>,
#[allow(non_snake_case)] removeAvatar: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "uploadStickerPack", param_kind = map)]
fn upload_sticker_pack(
&self,
account: Option<String>,
path: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "verify", param_kind = map)]
fn verify(
&self,
account: Option<String>,
#[allow(non_snake_case)] verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[subscription(
name = "subscribeReceive" => "receive",
unsubscribe = "unsubscribeReceive",
item = RecvMessage,
param_kind = map
)]
async fn subscribe_receive(&self, account: Option<String>) -> SubscriptionResult;
#[method(name = "version")]
fn version(&self) -> Result<Value, ErrorObjectOwned>;
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonLink {
pub device_link_uri: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecvMessage {
pub envelope: Envelope,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Envelope {
pub source: String,
pub source_number: String,
pub source_uuid: String,
pub source_name: String,
pub source_device: i64,
pub timestamp: u64,
pub data_message: Option<DataMessage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataMessage {
pub timestamp: u64,
pub message: String,
}
/// Connect to signal-cli over tcp socket
pub async fn connect_tcp(
tcp: impl ToSocketAddrs,
) -> Result<impl SubscriptionClientT, std::io::Error> {
let (sender, receiver) = super::transports::tcp::connect(tcp).await?;
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
}
impl Envelope {
/// Send a read-receipt for an envelope
pub async fn send_read_receipt(
&self,
client: &impl RpcClient,
account: impl Into<String>,
) -> Result<(), RpcClientError> {
if let Some(dm) = self.data_message.as_ref() {
let _ = client
.send_receipt(
Some(account.into()),
self.source_uuid.clone(),
vec![dm.timestamp],
"read".into(),
)
.await?;
}
Ok(())
}
}
/// Helper for invoking send, which has way too many parameters
pub struct SignalMessage {
pub sender: String,
pub recipient: Vec<String>,
pub message: String,
pub attachments: Vec<String>,
}
impl SignalMessage {
#[allow(non_snake_case)]
pub async fn send(self, client: &impl RpcClient) -> Result<(), RpcClientError> {
// See note about string indexing here: https://github.com/AsamK/signal-cli/wiki/FAQ#string-indexing-units
let message_len_utf16: usize = self.message.chars().map(|c| c.len_utf16()).sum();
/*
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
*/
let account = Some(self.sender);
let recipients = self.recipient;
let groupIds = vec![];
let noteToSelf = false;
let endSession = false;
let message = self.message;
let attachments = self.attachments;
let viewOnce = false;
let mentions = vec![];
let textStyle = vec![format!("0:{message_len_utf16}:MONOSPACE")];
let quoteTimestamp = None;
let quoteAuthor = None;
let quoteMention = vec![];
let quoteMessage = None;
let quoteTextStyle = vec![];
let quoteAttachment = vec![];
let previewUrl = None;
let previewTitle = None;
let previewDescription = None;
let previewImage = None;
let sticker = None;
let storyTimestamp = None;
let storyAuthor = None;
let editTimestamp = None;
let _resp = client
.send(
account,
recipients,
groupIds,
noteToSelf,
endSession,
message,
attachments,
viewOnce,
mentions,
textStyle,
quoteTimestamp,
quoteAuthor,
quoteMessage,
quoteMention,
quoteTextStyle,
quoteAttachment,
previewUrl,
previewTitle,
previewDescription,
previewImage,
sticker,
storyTimestamp,
storyAuthor,
editTimestamp,
)
.await?;
Ok(())
}
}
+147
View File
@@ -0,0 +1,147 @@
use conf::Conf;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use hyper_util::server::conn::auto;
use std::{str::FromStr, sync::Arc, time::Duration};
use syslog_rfc5424::SyslogMessage;
use tokio::net::{TcpListener, UdpSocket};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
pub mod config;
pub mod gateway;
pub mod http;
mod human_duration;
mod init_logging;
pub mod jsonrpc;
pub mod plotter;
pub mod transports;
use config::Config;
use gateway::Gateway;
#[tokio::main]
async fn main() {
init_logging::init_logging();
let config = Config::parse();
info!("Config = {config:#?}");
if config.dry_run {
return;
}
let token = CancellationToken::new();
let gateway = Arc::new(Gateway::new(config.gateway, token.clone()).await);
let listener = TcpListener::bind(config.http_listen_addr).await.unwrap();
info!("Listening for http on {}", config.http_listen_addr);
let udp_socket = UdpSocket::bind(config.udp_listen_addr).await.unwrap();
info!("Listening for udp on {}", config.udp_listen_addr);
// Listen for ctrl-c
let thread_token = token.clone();
tokio::task::spawn(async move {
tokio::signal::ctrl_c().await.unwrap();
warn!("ctrl-c: Stop requested");
thread_token.cancel();
});
// Start the two server tasks
let _http_task = start_http_task(listener, gateway.clone());
let _udp_task = start_udp_task(udp_socket, gateway.clone());
// Run gateway task and block on it returning. Note that it exits if the token is canceled.
gateway.run().await;
}
fn start_http_task(listener: TcpListener, gateway: Arc<Gateway>) -> tokio::task::JoinHandle<()> {
// Loop waiting for http incoming connections, and pass them to gateway
tokio::task::spawn(async move {
loop {
let Ok((stream, remote_addr)) = listener
.accept()
.await
.inspect_err(|err| error!("Error accepting connection: {err}"))
else {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
};
info!("New connection from: {}", remote_addr);
// Spawn a new task to handle each connection
let thread_gateway = gateway.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
// Serve the connection using auto protocol detection (HTTP/1 or HTTP/2)
if let Err(err) = auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(
io,
service_fn(|req| {
let thread_gateway = thread_gateway.clone();
async move { thread_gateway.handle_http_request(req).await }
}),
)
.await
{
error!("Error serving connection: {err}");
}
});
}
})
}
fn start_udp_task(udp_socket: UdpSocket, gateway: Arc<Gateway>) -> tokio::task::JoinHandle<()> {
// Loop waiting for http incoming connections, and pass them to gateway
tokio::task::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
let Ok((len, _addr)) = udp_socket
.recv_from(&mut buf)
.await
.inspect_err(|err| error!("Error receiving UDP packet: {err}"))
else {
continue;
};
let Ok(text) = str::from_utf8(&buf[0..len])
.inspect_err(|err| error!("UDP packet was not utf8: {err}"))
else {
continue;
};
let Ok(msg) = SyslogMessage::from_str(text)
.inspect_err(|err| error!("UDP packet was not valid syslog: {err}:\n{text}"))
else {
continue;
};
gateway.handle_syslog_message(msg).await;
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_syslog_parsing() {
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
)
.unwrap();
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
)
.unwrap();
SyslogMessage::from_str(
"<12>1 2025-11-08T02:24:10.815+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports due to staleness"
)
.unwrap();
}
}
+255
View File
@@ -0,0 +1,255 @@
use crate::http::Alert;
use chrono::Utc;
use conf::Conf;
use prom_client::{
AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricTimeseries, MetricValue,
PromRequest, QueryRangeRequest, QueryRequest, SeriesRequest,
plot::{PlotStyle, PlotThreshold},
};
use rand::RngCore;
use reqwest::Client as ReqwestClient;
use rust_decimal::Decimal;
use std::{error::Error, str::FromStr, time::Duration};
use tracing::{info, warn};
use walkdir::WalkDir;
#[derive(Clone, Conf, Debug)]
pub struct PlotterConfig {
#[conf(long, env)]
pub prometheus_host: String,
#[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)]
pub plot_age_limit: Duration,
}
pub struct Plotter {
config: PlotterConfig,
plot_dir: String,
reqwest_client: ReqwestClient,
skip_labels: Vec<String>,
}
impl Plotter {
pub fn new(config: PlotterConfig) -> Self {
let plot_dir = "/tmp".into();
let reqwest_client = ReqwestClient::new();
let skip_labels = vec!["job".into(), "instance".into()];
Self {
config,
plot_dir,
reqwest_client,
skip_labels,
}
}
fn create_plot(
&self,
matrix: &[MetricTimeseries],
threshold: Option<PlotThreshold>,
title: Option<&str>,
) -> Result<String, Box<dyn Error>> {
let filename = format!(
"{dir}/plot-{num}.png",
dir = self.plot_dir,
num = rand::rng().next_u64()
);
let mut plot_style = PlotStyle::default().dark_mode();
plot_style.skip_labels = self.skip_labels.clone();
if let Some(title) = title {
plot_style.title = Some(title.to_owned());
}
plot_style.plot_timeseries(&filename, matrix, threshold)?;
Ok(filename)
}
pub fn purge_old_plots(&self) {
for entry in WalkDir::new(&self.plot_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let Some(ext) = entry.path().extension() else {
continue;
};
if ext != "png" && ext != ".png" {
continue;
}
let path = entry.path().display();
let Ok(metadata) = entry
.metadata()
.inspect_err(|err| warn!("Couldn't get metadata for {path}: {err}"))
else {
continue;
};
let Ok(time) = metadata
.created()
.inspect_err(|err| warn!("Couldn't get creation time for {path}: {err}"))
else {
continue;
};
let Ok(elapsed) = time
.elapsed()
.inspect_err(|err| warn!("Elapsed time calculation failed for {path}: {err}"))
else {
continue;
};
if elapsed > self.config.plot_age_limit
&& let Err(err) = std::fs::remove_file(entry.path())
{
warn!("Couldn't remove old png file {path}: {err}");
}
}
}
pub async fn create_alert_plot(&self, alert: &Alert) -> Result<String, Box<dyn Error>> {
let expr = alert.parse_expr_from_generator_url()?;
// Parse expressions like "query < 0.09" or "query < 0.09 and on (instance) up{...}"
// We look for comparison operators and extract the threshold
let (base_query, threshold) = parse_alert_expr(&expr)?;
// Build label selector from alert labels (excluding job/instance)
let label_selector = build_label_selector(&alert.labels, &self.skip_labels);
let query = if label_selector.is_empty() {
base_query.to_owned()
} else {
format!("{base_query}{{{label_selector}}}")
};
let now = Utc::now();
let elapsed = now - alert.starts_at;
// Extend elapsed by 210%, but use at least plot_age_limit (default 30m)
let lengthen = elapsed
.checked_mul(31)
.and_then(|e| e.checked_div(10))
.ok_or("timedelta overflow")?;
let min_range = chrono::TimeDelta::from_std(self.config.plot_age_limit)?;
let range = lengthen.max(min_range);
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.clone())
.range(now - range..now)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.create_plot(&matrix, Some(threshold), Some(&query))
}
pub async fn create_oneoff_plot(
&self,
query: String,
since: Duration,
) -> Result<String, Box<dyn Error>> {
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.to_owned())
.since(since)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.create_plot(&matrix, None, Some(&query))
}
#[allow(clippy::type_complexity)]
pub async fn oneoff_query(
&self,
query: String,
) -> Result<(ExtractLabels, Vec<Option<(f64, Decimal)>>), Box<dyn Error>> {
info!("Prom query: {query}");
let vector: Vec<MetricValue> = QueryRequest { query, time: None }
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_vector()?;
let labels = ExtractLabels::new(vector.iter().map(|mv| &mv.metric), &self.skip_labels);
let values = vector.into_iter().map(|mv| mv.value).collect();
Ok((labels, values))
}
pub async fn series(
&self,
matches: impl IntoIterator<Item: AsRef<str>>,
) -> Result<Vec<Labels>, Box<dyn Error>> {
Ok(SeriesRequest {
matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(),
}
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?)
}
pub async fn labels(
&self,
matches: impl IntoIterator<Item: AsRef<str>>,
) -> Result<Vec<String>, Box<dyn Error>> {
Ok(LabelsRequest {
matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(),
}
.send_with_client::<()>(&self.reqwest_client, &self.config.prometheus_host)
.await?)
}
pub async fn alerts(&self) -> Result<Vec<AlertInfo>, Box<dyn Error>> {
Ok(AlertsRequest {}
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.alerts)
}
}
/// Build a prometheus label selector string from alert labels, excluding specified labels.
/// E.g., {"asset": "ETH", "job": "ec2"} with skip=["job"] -> `asset="ETH"`
fn build_label_selector(
labels: &std::collections::BTreeMap<String, String>,
skip: &[String],
) -> String {
labels
.iter()
.filter(|(k, _)| !skip.contains(k) && *k != "alertname")
.map(|(k, v)| format!("{k}=\"{v}\""))
.collect::<Vec<_>>()
.join(",")
}
/// Parse an alert expression to extract the base query and threshold.
///
/// Handles expressions like:
/// - "query < 0.09"
/// - "query > 100"
/// - "rate(tick_successes[5m]) < 0.09 and on (instance) up{job=\"ec2\"}"
///
/// Returns (base_query, threshold) where base_query is the part before the comparator.
fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), Box<dyn std::error::Error>> {
// Look for comparison operators with surrounding spaces
for comparator in [" < ", " > "] {
if let Some(pos) = expr.find(comparator) {
let base_query = expr[..pos].to_owned();
let after_comparator = &expr[pos + comparator.len()..];
// The threshold is the first space-delimited token after the comparator
let numeric = after_comparator
.split_whitespace()
.next()
.ok_or("no numeric after comparator")?;
let limit = f64::from_str(numeric)?;
let threshold = if comparator == " < " {
PlotThreshold::LessThan(limit)
} else {
PlotThreshold::GreaterThan(limit)
};
return Ok((base_query, threshold));
}
}
Err("no comparator (< or >) found in expression".into())
}
+60
View File
@@ -0,0 +1,60 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
use futures_util::{Sink, SinkExt, Stream, stream::StreamExt};
use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT};
use thiserror::Error;
mod stream_codec;
pub mod tcp;
#[derive(Debug, Error)]
enum Errors {
#[error("Other: {0}")]
Other(String),
#[error("Closed")]
Closed,
}
struct Sender<T: Send + Sink<String>> {
inner: T,
}
impl<T: Send + Sink<String, Error = impl std::error::Error> + Unpin + 'static> TransportSenderT
for Sender<T>
{
type Error = Errors;
async fn send(&mut self, body: String) -> Result<(), Self::Error> {
self.inner
.send(body)
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
async fn close(&mut self) -> Result<(), Self::Error> {
self.inner
.close()
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
}
struct Receiver<T: Send + Stream> {
inner: T,
}
impl<T: Send + Stream<Item = Result<String, std::io::Error>> + Unpin + 'static> TransportReceiverT
for Receiver<T>
{
type Error = Errors;
async fn receive(&mut self) -> Result<ReceivedMessage, Self::Error> {
match self.inner.next().await {
None => Err(Errors::Closed),
Some(Ok(msg)) => Ok(ReceivedMessage::Text(msg)),
Some(Err(e)) => Err(Errors::Other(format!("{e:?}"))),
}
}
}
@@ -0,0 +1,63 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
use std::{io, str};
use tokio_util::bytes::BytesMut;
use tokio_util::codec::{Decoder, Encoder};
type Separator = u8;
/// Stream codec for streaming protocols (ipc, tcp)
#[derive(Debug, Default)]
pub struct StreamCodec {
incoming_separator: Separator,
outgoing_separator: Separator,
}
impl StreamCodec {
/// Default codec with streaming input data. Input can be both enveloped and not.
pub fn stream_incoming() -> Self {
StreamCodec::new(b'\n', b'\n')
}
/// New custom stream codec
pub fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self {
StreamCodec {
incoming_separator,
outgoing_separator,
}
}
}
impl Decoder for StreamCodec {
type Item = String;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Self::Item>> {
if let Some(i) = buf
.as_ref()
.iter()
.position(|&b| b == self.incoming_separator)
{
let line = buf.split_to(i);
let _ = buf.split_to(1);
match str::from_utf8(line.as_ref()) {
Ok(s) => Ok(Some(s.to_string())),
Err(_) => Err(io::Error::other("invalid UTF-8")),
}
} else {
Ok(None)
}
}
}
impl Encoder<String> for StreamCodec {
type Error = io::Error;
fn encode(&mut self, msg: String, buf: &mut BytesMut) -> io::Result<()> {
let mut payload = msg.into_bytes();
payload.push(self.outgoing_separator);
buf.extend_from_slice(&payload);
Ok(())
}
}
+22
View File
@@ -0,0 +1,22 @@
use std::io::Error;
use futures_util::stream::StreamExt;
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio_util::codec::Decoder;
use super::stream_codec::StreamCodec;
use super::{Receiver, Sender};
/// Connect to a JSON-RPC TCP server.
pub async fn connect(
socket: impl ToSocketAddrs,
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
let connection = TcpStream::connect(socket).await?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sender = Sender { inner: sink };
let receiver = Receiver { inner: stream };
Ok((sender, receiver))
}