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: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ cargo run -- serve
## Self-hosting with Docker

Published images are available from GitHub Container Registry after a release tag:
`ghcr.io/techgodhq/iris:<version>` (or `:latest`). Iris currently has **no
HTTP authentication** (tracked in COD-429), so bind it to localhost or place it
only on a private network behind your own authenticated proxy.
`ghcr.io/techgodhq/iris:<version>` (or `:latest`). Set `IRIS_API_TOKEN` to a
high-entropy secret in every internet-reachable deployment; every HTTP endpoint
except `GET /health` then requires `Authorization: Bearer <token>`. Iris compares
tokens without early exit and does not encode assumptions about any particular
network product. Without a token, `iris serve` emits a conspicuous warning and
refuses public or wildcard bind addresses; it allows only numeric loopback,
private, carrier-grade-NAT, or IPv6 unique-local addresses.

```bash
curl -H "Authorization: Bearer ${IRIS_API_TOKEN}" http://127.0.0.1:9876/providers
```

```bash
docker run --rm \
Expand Down
40 changes: 39 additions & 1 deletion crates/iris-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,43 @@ pub fn list_providers() -> anyhow::Result<()> {
Ok(())
}

/// Reject public listening addresses when no HTTP bearer token is configured.
///
/// This intentionally names no overlay network: loopback, RFC1918, carrier-grade
/// NAT, and IPv6 unique-local addresses are safe deployment classes regardless of
/// which network product provides them.
fn validate_unauthenticated_bind(addr: &str, has_api_token: bool) -> anyhow::Result<()> {
if has_api_token {
return Ok(());
}
let address: std::net::SocketAddr = addr.parse().map_err(|_| {
anyhow::anyhow!("--addr must be a numeric socket address when IRIS_API_TOKEN is unset")
})?;
let private = match address.ip() {
std::net::IpAddr::V4(ip) => {
ip.is_loopback()
|| ip.is_private()
|| (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]))
}
std::net::IpAddr::V6(ip) => ip.is_loopback() || (ip.segments()[0] & 0xfe00) == 0xfc00,
};
anyhow::ensure!(
private,
"IRIS_API_TOKEN is required when binding Iris to a public address"
);
Ok(())
}

pub async fn serve(args: ServeArgs) -> anyhow::Result<()> {
let api_token = std::env::var("IRIS_API_TOKEN")
.ok()
.filter(|token| !token.trim().is_empty());
validate_unauthenticated_bind(&args.addr, api_token.is_some())?;
if api_token.is_none() {
tracing::warn!(
"IRIS_API_TOKEN is unset: HTTP API authentication is disabled; binding is restricted to a non-public address"
);
}
let store = attachment_store();
let audit = audit_log();
let providers = get_providers_from_path(args.config.as_deref(), &store, &audit)?;
Expand All @@ -339,13 +375,15 @@ pub async fn serve(args: ServeArgs) -> anyhow::Result<()> {
None => load_default_config()?,
};
let (ingest, ingest_sources, ingest_secrets) = ingest_configuration(&config);
let app = iris_server::create_app_with_ingest(
let app = iris_server::create_app_with_ingest_sse_and_api_token(
providers.clone(),
store,
audit,
ingest,
ingest_sources,
ingest_secrets,
iris_server::sse::SseSettings::default(),
api_token,
);

let listener = tokio::net::TcpListener::bind(&args.addr).await?;
Expand Down
29 changes: 29 additions & 0 deletions crates/iris-server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub struct AppState {
pub ingest_secrets: BTreeMap<String, Arc<str>>,
/// SSE delivery settings (wire-idle heartbeat interval).
pub sse: SseSettings,
/// Optional static bearer token for the general HTTP API.
pub api_token: Option<Arc<str>>,
}

/// Creates the Axum application with all routes wired.
Expand Down Expand Up @@ -93,6 +95,30 @@ pub fn create_app_with_ingest_and_sse(
ingest_sources: impl IntoIterator<Item = String>,
ingest_secrets: BTreeMap<String, String>,
sse: SseSettings,
) -> Router {
create_app_with_ingest_sse_and_api_token(
providers,
attachments,
audit,
ingest,
ingest_sources,
ingest_secrets,
sse,
None,
)
}

/// Creates the application with explicit ingestion, SSE, and HTTP bearer-token settings.
#[allow(clippy::too_many_arguments)]
pub fn create_app_with_ingest_sse_and_api_token(
providers: Vec<Arc<dyn MessageProvider>>,
attachments: Arc<dyn AttachmentStore>,
audit: Arc<dyn AuditLog>,
ingest: Option<Arc<dyn IngestStore>>,
ingest_sources: impl IntoIterator<Item = String>,
ingest_secrets: BTreeMap<String, String>,
sse: SseSettings,
api_token: Option<String>,
) -> Router {
let ingest_sources = ingest_sources.into_iter().collect();
// A blank configuration value must never turn into a valid empty bearer token.
Expand All @@ -110,6 +136,9 @@ pub fn create_app_with_ingest_and_sse(
ingest_sources,
ingest_secrets,
sse,
api_token: api_token
.filter(|token| !token.trim().is_empty())
.map(Arc::from),
};
routes::router(state)
}
5 changes: 4 additions & 1 deletion crates/iris-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ pub mod app;
pub mod routes;
pub mod sse;

pub use app::{create_app, create_app_with_ingest, create_app_with_sse};
pub use app::{
create_app, create_app_with_ingest, create_app_with_ingest_sse_and_api_token,
create_app_with_sse,
};
pub use sse::SseSettings;
41 changes: 40 additions & 1 deletion crates/iris-server/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,37 @@ pub fn router(state: AppState) -> Router {
.any(|route| route.name == "subscribe_events" && route.path == "/v1/events"),
"generated SSE metadata must cover the runtime-bound subscribe_events route"
);
router.with_state(state)
router
.layer(middleware::from_fn_with_state(state.clone(), api_auth))
.with_state(state)
}

/// Authenticate every HTTP endpoint except the liveness probe.
async fn api_auth(
State(state): State<AppState>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
if request.uri().path() == "/health" || state.api_token.is_none() {
return next.run(request).await;
}
let authorization = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
if authorization.is_none_or(|provided| {
!constant_time_secret_eq(state.api_token.as_deref().expect("checked above"), provided)
}) {
return (
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "invalid or missing bearer authorization".to_string(),
}),
)
.into_response();
}
next.run(request).await
}

const MAX_INGEST_BODY_BYTES: usize = 1024 * 1024;
Expand Down Expand Up @@ -952,6 +982,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
}
}

Expand Down Expand Up @@ -1180,6 +1211,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};
let _router = super::router(app_state);
}
Expand Down Expand Up @@ -1278,6 +1310,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};
let app = router(app_state);
let providers = app
Expand Down Expand Up @@ -1502,6 +1535,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};
let mut input = input_with_thread(thread_id, &[]);
input.body = serde_json::json!({
Expand Down Expand Up @@ -1630,6 +1664,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};

// Retrieve via the HTTP handler.
Expand Down Expand Up @@ -1666,6 +1701,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};

let random_id = Uuid::new_v4();
Expand All @@ -1691,6 +1727,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};

let response = get_attachment_content(
Expand Down Expand Up @@ -1727,6 +1764,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};

let response = get_attachment_content(
Expand Down Expand Up @@ -1770,6 +1808,7 @@ mod tests {
ingest_sources: std::collections::BTreeSet::new(),
ingest_secrets: std::collections::BTreeMap::new(),
sse: crate::sse::SseSettings::default(),
api_token: None,
};

let response = get_attachment_content(
Expand Down
Loading