Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ authors = ["TechGodHQ"]
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
base64 = "0.22"
axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
Rite is a self-hostable, minimal event-to-action runtime. It receives authenticated events, normalizes them, matches TOML-configured handlers, and forwards matching events to actions.

```text
GitHub webhook ──> Rite ──> configured HTTP action
Iris event ──> Rite ──> configured HTTP action
GitHub webhook ──> Rite ──> configured HTTP action
Uptime Kuma webhook ──> Rite ──> configured HTTP action
Iris event ──> Rite ──> configured HTTP action
```

## Quick start
Expand All @@ -23,6 +24,11 @@ action = { type = "http_post", url = "https://example.test/hooks/pr" }
[sources.iris]
enabled = true
base_url = "http://127.0.0.1:3000"

# Required when this ingress source is enabled.
[sources.uptime_kuma]
enabled = true
secret = "change-me"
```

Run it:
Expand All @@ -36,6 +42,7 @@ Endpoints:
- `GET /health` — returns `ok`
- `GET /sources` — configured source adapters
- `POST /event/github` — authenticated GitHub webhook ingress using `X-Hub-Signature-256`
- `POST /event/uptime_kuma` — authenticated Uptime Kuma webhook ingress using base64 `Signature` HMAC-SHA256

## CLI and MCP

Expand Down Expand Up @@ -109,4 +116,4 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check
```

Rite currently normalizes GitHub `push` and `pull_request` payloads, matches configured handlers, and executes `http_post` actions with the normalized event as JSON.
Rite normalizes GitHub `push` and `pull_request` payloads plus Uptime Kuma heartbeats, matches configured handlers, and executes `http_post` actions with the normalized event as JSON. Uptime Kuma heartbeats require a configured secret; `status` maps deterministically to `up` (info), `down` (critical), `pending` (warning), or `maintenance` (info). Matching-safe metadata includes `monitor_name`, `monitor_id`, `status`, plus present URL/timing fields; Kuma's `msg` becomes the optional event body.
1 change: 1 addition & 0 deletions crates/rite-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ tracing.workspace = true
url.workspace = true

[dev-dependencies]
base64.workspace = true
hmac.workspace = true
hex.workspace = true
sha2.workspace = true
Expand Down
22 changes: 16 additions & 6 deletions crates/rite-server/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! the same status/message pair.

use axum::{Json, http::StatusCode, response::IntoResponse};
use rite_core::{EventSource, RiteAction};
use rite_core::RiteAction;
use serde_json::{Value, json};
use std::collections::BTreeMap;

Expand Down Expand Up @@ -154,13 +154,16 @@ pub async fn execute_raw_operation_http(
/// 404 unknown source, 401 failed verification, 400 unparseable payload,
/// 202 with a JSON ack once handlers have run.
async fn receive_event(state: &AppState, input: RawOperationInput) -> axum::response::Response {
if input.path.get("source").map(String::as_str) != Some("github") {
let Some(source_id) = input.path.get("source") else {
return (StatusCode::NOT_FOUND, "unknown event source").into_response();
}
if let Err(error) = state.github.verify(&input.headers, &input.raw_body).await {
};
let Some(source) = state.sources.get(source_id) else {
return (StatusCode::NOT_FOUND, "unknown event source").into_response();
};
if let Err(error) = source.verify(&input.headers, &input.raw_body).await {
return (StatusCode::UNAUTHORIZED, error.to_string()).into_response();
}
let event = match state.github.parse(&input.headers, &input.raw_body).await {
let event = match source.parse(&input.headers, &input.raw_body).await {
Ok(event) => event,
Err(error) => return (StatusCode::BAD_REQUEST, error.to_string()).into_response(),
};
Expand Down Expand Up @@ -244,7 +247,14 @@ async fn receive_event(state: &AppState, input: RawOperationInput) -> axum::resp

#[allow(clippy::unused_async)]
async fn list_sources(state: &AppState) -> Result<Value, OperationError> {
let mut sources = vec![json!({"id": "github", "name": "GitHub"})];
let mut sources = state
.sources
.values()
.map(|source| {
let metadata = source.metadata();
json!({"id": metadata.id, "name": metadata.name})
})
.collect::<Vec<_>>();
if state.iris.is_some() {
sources.push(json!({"id": "iris", "name": "Iris"}));
}
Expand Down
121 changes: 105 additions & 16 deletions crates/rite-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pub mod dispatch;

use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
Expand All @@ -11,8 +12,8 @@ use std::{
};

use axum::{Json, Router, routing::get};
use rite_core::{RiteAction, RiteHandler};
use rite_sources::{github::GitHubSource, iris::IrisSource};
use rite_core::{EventSource, RiteAction, RiteHandler};
use rite_sources::{github::GitHubSource, iris::IrisSource, uptime_kuma::UptimeKumaSource};
use serde::Deserialize;

/// Severity emitted while checking a loaded configuration before the server starts.
Expand Down Expand Up @@ -47,6 +48,16 @@ pub struct RiteConfig {
pub struct SourcesConfig {
/// Optional Iris SSE subscription source.
pub iris: Option<IrisConfig>,
/// Optional authenticated Uptime Kuma webhook source.
pub uptime_kuma: Option<UptimeKumaConfig>,
}

/// Uptime Kuma webhook configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct UptimeKumaConfig {
#[serde(default)]
pub enabled: bool,
pub secret: String,
}

/// Iris subscription configuration.
Expand All @@ -61,7 +72,8 @@ pub struct IrisConfig {
#[derive(Clone)]
pub struct AppState {
pub handlers: Arc<Vec<RiteHandler>>,
pub github: Arc<GitHubSource>,
/// Ingress sources keyed by their stable source ID.
pub sources: Arc<BTreeMap<String, Arc<dyn EventSource>>>,
pub client: reqwest::Client,
pub iris: Option<IrisSource>,
pub metrics: Arc<Metrics>,
Expand Down Expand Up @@ -113,10 +125,14 @@ pub fn load_config(input: &str) -> Result<RiteConfig, toml::de::Error> {
#[must_use]
pub fn validate(config: &RiteConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let configured_sources = [Some("github"), config.sources.iris.as_ref().map(|_| "iris")]
.into_iter()
.flatten()
.collect::<std::collections::BTreeSet<_>>();
let configured_sources = [
Some("github"),
config.sources.iris.as_ref().map(|_| "iris"),
config.sources.uptime_kuma.as_ref().map(|_| "uptime_kuma"),
]
.into_iter()
.flatten()
.collect::<std::collections::BTreeSet<_>>();

if config.rites.is_empty() {
diagnostics.push(Diagnostic {
Expand All @@ -134,6 +150,16 @@ pub fn validate(config: &RiteConfig) -> Vec<Diagnostic> {
});
}

if let Some(kuma) = &config.sources.uptime_kuma
&& kuma.enabled
&& kuma.secret.trim().is_empty()
{
diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
message: "enabled source 'uptime_kuma' has an empty secret".into(),
});
}

let mut names = std::collections::BTreeSet::new();
for handler in &config.rites {
if !configured_sources.contains(handler.source.as_str()) {
Expand All @@ -158,14 +184,23 @@ pub fn validate(config: &RiteConfig) -> Vec<Diagnostic> {
/// A secret-free configuration inventory for startup logs.
#[must_use]
pub fn startup_summary(config: &RiteConfig) -> String {
let source_count = 1 + usize::from(config.sources.iris.is_some());
let enabled_count = 1 + usize::from(
config
.sources
.iris
.as_ref()
.is_some_and(|source| source.enabled),
);
let source_count = 1
+ usize::from(config.sources.iris.is_some())
+ usize::from(config.sources.uptime_kuma.is_some());
let enabled_count =
1 + usize::from(
config
.sources
.iris
.as_ref()
.is_some_and(|source| source.enabled),
) + usize::from(
config
.sources
.uptime_kuma
.as_ref()
.is_some_and(|source| source.enabled),
);
format!(
"rite: {source_count} sources ({enabled_count} enabled), {} handlers loaded",
config.rites.len()
Expand Down Expand Up @@ -197,9 +232,17 @@ pub fn configured_state(secret: &str, config: RiteConfig) -> rite_core::Result<A
.filter(|config| config.enabled)
.map(|config| IrisSource::new(config.base_url))
.transpose()?;
let mut sources: BTreeMap<String, Arc<dyn EventSource>> = BTreeMap::new();
sources.insert("github".into(), Arc::new(GitHubSource::new(secret)?));
if let Some(kuma) = config.sources.uptime_kuma.filter(|config| config.enabled) {
sources.insert(
"uptime_kuma".into(),
Arc::new(UptimeKumaSource::new(kuma.secret)?),
);
}
Ok(AppState {
handlers: Arc::new(config.rites),
github: Arc::new(GitHubSource::new(secret)?),
sources: Arc::new(sources),
client: reqwest::Client::new(),
iris,
metrics: Arc::new(Metrics::default()),
Expand Down Expand Up @@ -286,6 +329,14 @@ mod tests {
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}

fn kuma_signature(secret: &str, body: &[u8]) -> String {
use base64::{Engine as _, engine::general_purpose::STANDARD};

let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("valid key");
mac.update(body);
STANDARD.encode(mac.finalize().into_bytes())
}

#[test]
fn validation_reports_invalid_configuration() {
let config = load_config(
Expand Down Expand Up @@ -384,6 +435,44 @@ mod tests {
assert_eq!(status["actions_failed"], 0);
}

#[tokio::test]
async fn uptime_kuma_ingress_requires_signature_and_acks_valid_heartbeats() {
let config =
load_config("[sources.uptime_kuma]\nenabled = true\nsecret = \"kuma-secret\"\n")
.expect("config parses");
let app = app(configured_state("github-secret", config).expect("valid state"));
let body = br#"{"monitor":{"id":7,"name":"API"},"heartbeat":{"status":0,"msg":"connection refused"}}"#;
let response = app
.clone()
.oneshot(
Request::post("/event/uptime_kuma")
.header("Signature", kuma_signature("kuma-secret", body))
.body(Body::from(body.as_slice()))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert!(
std::str::from_utf8(&bytes)
.expect("utf8")
.contains("uptime_kuma")
);

let rejected = app
.oneshot(
Request::post("/event/uptime_kuma")
.body(Body::from(body.as_slice()))
.expect("request"),
)
.await
.expect("response");
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn invalid_signature_is_unauthorized() {
let state = configured_state("secret", RiteConfig::default()).expect("valid state");
Expand Down
1 change: 1 addition & 0 deletions crates/rite-sources/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ license.workspace = true

[dependencies]
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
futures-util.workspace = true
hex.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/rite-sources/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

pub mod github;
pub mod iris;
pub mod uptime_kuma;
Loading
Loading