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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ starts the server; omitting the subcommand remains equivalent for compatibility.
endpoints: `GET /sources`, `GET /handlers`, and `GET /handlers/{handler_id}`. No separate server
surface is needed.

When enabled, the Iris source subscribes to `GET /v1/events` in the background. It reconnects
with exponential backoff (1–60 seconds), so Rite remains healthy while Iris is unavailable.
Iris messages become `source = "iris"` events. `event_type` is the Iris message kind and metadata
When enabled, the Iris source subscribes to `GET /v1/events` in the background. Set
`sources.iris.api_token` (or `RITE_IRIS_API_TOKEN` in Docker) when Iris requires bearer
authentication; Rite sends it only as the subscription's `Authorization: Bearer` header, including
on reconnects. Without a token Rite warns at startup so legacy unauthenticated Iris deployments
remain supported. It reconnects with exponential backoff (1–60 seconds), so Rite remains healthy
while Iris is unavailable. Iris messages become `source = "iris"` events. `event_type` is the Iris message kind and metadata
contains `provider`, `source_id`, `sender`, `kind`, `iris_metadata`, and the complete original
Iris message under `message` for future matching needs.

Expand All @@ -73,11 +76,14 @@ docker run --rm \
--publish 127.0.0.1:8080:8080 \
--env RITE_GITHUB_WEBHOOK_SECRET="${RITE_GITHUB_WEBHOOK_SECRET}" \
--env RITE_IRIS_BASE_URL="http://iris.internal:9876" \
--env RITE_IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN}" \
ghcr.io/techgodhq/rite:latest
```

`RITE_GITHUB_WEBHOOK_SECRET` is required for authenticated GitHub webhook
ingress. Omit `RITE_IRIS_BASE_URL` when you do not want an Iris subscription.
ingress. Omit `RITE_IRIS_BASE_URL` when you do not want an Iris subscription. Set
`RITE_IRIS_API_TOKEN` whenever that Iris instance has `IRIS_API_TOKEN` configured; it is not
written to logs.
For a complete private-network example with both services, use the Iris
repository's [`deploy/docker-compose.yml`](https://github.com/TechGodHQ/iris/blob/main/deploy/docker-compose.yml).

Expand Down
20 changes: 19 additions & 1 deletion crates/rite-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub struct IrisConfig {
#[serde(default)]
pub enabled: bool,
pub base_url: String,
#[serde(default)]
pub api_token: Option<String>,
}

/// Shared HTTP application state.
Expand Down Expand Up @@ -133,6 +135,22 @@ pub fn validate(config: &RiteConfig) -> Vec<Diagnostic> {
message: "enabled source 'iris' has an empty base_url".into(),
});
}
if let Some(iris) = &config.sources.iris
&& iris.enabled
&& !iris.base_url.trim().is_empty()
{
match iris.api_token.as_deref() {
Some(token) if token.trim().is_empty() => diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
message: "enabled source 'iris' has a blank api_token".into(),
}),
None => diagnostics.push(Diagnostic {
level: DiagnosticLevel::Warning,
message: "enabled source 'iris' has no api_token; this only works with unauthenticated Iris".into(),
}),
Some(_) => {}
}
}

let mut names = std::collections::BTreeSet::new();
for handler in &config.rites {
Expand Down Expand Up @@ -195,7 +213,7 @@ pub fn configured_state(secret: &str, config: RiteConfig) -> rite_core::Result<A
.sources
.iris
.filter(|config| config.enabled)
.map(|config| IrisSource::new(config.base_url))
.map(|config| IrisSource::new_with_token(config.base_url, config.api_token.as_deref()))
.transpose()?;
Ok(AppState {
handlers: Arc::new(config.rites),
Expand Down
73 changes: 70 additions & 3 deletions crates/rite-sources/src/iris.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::{collections::BTreeMap, time::Duration};

use chrono::{DateTime, Utc};
use futures_util::StreamExt;
use reqwest::header::{AUTHORIZATION, HeaderValue};
use rite_core::{Result, RiteError, RiteEvent, Severity, SourceMetadata};
use serde::Deserialize;
use serde_json::{Value, json};
Expand Down Expand Up @@ -41,8 +42,13 @@ struct IrisSender {
}

impl IrisSource {
/// Creates an Iris source for an HTTP base URL.
/// Creates an unauthenticated Iris source for an HTTP base URL.
pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
Self::new_with_token(base_url, None)
}

/// Creates an Iris source for an HTTP base URL and optional bearer token.
pub fn new_with_token(base_url: impl AsRef<str>, api_token: Option<&str>) -> Result<Self> {
let base_url = base_url.as_ref().trim().trim_end_matches('/');
let parsed = reqwest::Url::parse(base_url)
.map_err(|error| RiteError::Config(format!("invalid Iris base URL: {error}")))?;
Expand All @@ -51,9 +57,25 @@ impl IrisSource {
"Iris base URL must use http or https".into(),
));
}

let mut headers = reqwest::header::HeaderMap::new();
if let Some(token) = api_token {
let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
RiteError::Config("Iris API token contains invalid header characters".into())
})?;
value.set_sensitive(true);
headers.insert(AUTHORIZATION, value);
}
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(|error| {
RiteError::Config(format!("failed to configure Iris client: {error}"))
})?;

Ok(Self {
base_url: base_url.into(),
client: reqwest::Client::new(),
client,
})
}

Expand Down Expand Up @@ -180,7 +202,7 @@ impl IrisSource {
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[test]
fn preserves_the_full_iris_message_for_matching() {
Expand Down Expand Up @@ -228,4 +250,49 @@ mod tests {
assert_eq!(event.source, "iris");
assert_eq!(event.body.as_deref(), Some("héllo"));
}

#[tokio::test]
async fn sends_bearer_token_only_when_configured() {
for token in [Some("test-token"), None] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener binds");
let address = listener.local_addr().expect("listener address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("client connects");
let mut request = Vec::new();
loop {
let mut chunk = [0; 1024];
let bytes = socket.read(&mut chunk).await.expect("request reads");
if bytes == 0 {
break;
}
request.extend_from_slice(&chunk[..bytes]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n")
.await
.expect("response writes");
String::from_utf8(request).expect("request is UTF-8")
});
let source = IrisSource::new_with_token(format!("http://{address}"), token)
.expect("source configures");
let (sender, _receiver) = mpsc::channel(1);
source
.consume_stream(&sender)
.await
.expect("stream consumes");
let request = server.await.expect("server completes");
let authorization = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"));
assert_eq!(authorization.is_some(), token.is_some());
if let Some(authorization) = authorization {
assert!(authorization.starts_with("authorization: Bearer "));
}
}
}
}
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ if [ -f /etc/rite/rite.mounted.toml ]; then
fi

IRIS_URL="${RITE_IRIS_BASE_URL:-}"
IRIS_API_TOKEN="${RITE_IRIS_API_TOKEN:-}"
IRIS_API_TOKEN_TOML=$(printf '%s' "$IRIS_API_TOKEN" | sed "s/'/''/g")

{
echo "# Generated by docker-entrypoint.sh — do not edit in place."
Expand All @@ -21,6 +23,9 @@ IRIS_URL="${RITE_IRIS_BASE_URL:-}"
echo "[sources.iris]"
echo "enabled = true"
echo "base_url = \"$IRIS_URL\""
if [ -n "$IRIS_API_TOKEN" ]; then
echo "api_token = '$IRIS_API_TOKEN_TOML'"
fi
fi
} > "$CONF"

Expand Down
1 change: 1 addition & 0 deletions rite.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ action = { type = "http_post", url = "https://example.test/hooks/pr" }
[sources.iris]
enabled = true
base_url = "http://127.0.0.1:3000"
# api_token = "set-through-your-secret-manager"

[[rites]]
name = "urgent-telegram"
Expand Down
Loading