diff --git a/Cargo.lock b/Cargo.lock index 7e95ae8..8ebc9c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,6 +1127,7 @@ dependencies = [ "tokio-test", "tokio-tungstenite", "toml 0.8.23", + "tower", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 01c16d7..5da08e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ futures-util = "0.3" [dev-dependencies] tokio-test = "0.4" +tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index 955e9b8..d142b12 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Or create a `.env` file (git-ignored): ``` OWNCAST_ACCESS_TOKEN=your-token-here +WEBHOOK_SECRET=some-random-secret ``` **4. Configure the Owncast webhook** @@ -36,9 +37,11 @@ OWNCAST_ACCESS_TOKEN=your-token-here In your Owncast admin, go to **Integrations > Webhooks** and add a webhook pointing to: ``` -http://:9078/webhook +http://:9078/webhook?secret=some-random-secret ``` +If `WEBHOOK_SECRET` is set, the bridge rejects any request that doesn't include a matching `?secret=` query parameter. If unset, all requests are accepted (a warning is logged at startup). + Select the events: **Chat Message**, **Stream Started**, **Stream Stopped**. **5. Run it** @@ -82,7 +85,13 @@ See [`config.example.toml`](config.example.toml) for all options. The only requi | `bridge` | `owncast_prefix` | `[OC]` | Prefix for Owncast messages in IRC | | `control` | `socket_path` | `/tmp/owncast-irc-bridge.sock` | Unix socket for `bridge-ctl` | -The access token is always read from the `OWNCAST_ACCESS_TOKEN` environment variable (not the config file). +Secrets are always read from environment variables (not the config file): + +| Variable | Required | Description | +|----------|----------|-------------| +| `OWNCAST_ACCESS_TOKEN` | Yes | Owncast integration API token | +| `IRC_PASSWORD` | No | IRC server password (PASS command) | +| `WEBHOOK_SECRET` | No | Shared secret for webhook authentication | ## Runtime Control diff --git a/chat-summaries/2026-03-12_22-00-summary.md b/chat-summaries/2026-03-12_22-00-summary.md new file mode 100644 index 0000000..05f0691 --- /dev/null +++ b/chat-summaries/2026-03-12_22-00-summary.md @@ -0,0 +1,17 @@ +# IRC Server Password Support + +## Task +Add support for connecting to password-protected IRC servers by sending the PASS command during connection, and allow setting a custom IRC username. + +## Changes + +- **src/config.rs**: Added `username: Option` field to `IrcConfig`. Added `BridgeConfig::irc_server_password()` method that reads `IRC_PASSWORD` from the environment (returns `Option`). Updated `default_for_test()` and tests for the new field and method. +- **src/irc_task.rs**: Set `password` and `username` on the `irc` crate's `Config` when building the IRC connection, sourcing the password from `BridgeConfig::irc_server_password()` and username from `IrcConfig.username`. +- **config.toml**: Removed stale `server_password` field, added comment about `IRC_PASSWORD` env var. +- **config.example.toml**: Added commented-out `username` field and `IRC_PASSWORD` env var documentation. + +## Usage +Set the `IRC_PASSWORD` environment variable before running the bridge to authenticate with the IRC server. Optionally set `username` in `[irc]` config for a custom ident. + +## Follow-up +- None identified. diff --git a/chat-summaries/2026-03-13_12-00-summary.md b/chat-summaries/2026-03-13_12-00-summary.md new file mode 100644 index 0000000..969e19f --- /dev/null +++ b/chat-summaries/2026-03-13_12-00-summary.md @@ -0,0 +1,40 @@ +# Webhook Authentication Guard + +**Date:** 2026-03-13 + +## Task + +Add a shared secret (`WEBHOOK_SECRET`) to the webhook endpoint so only requests with a matching `?secret=` query parameter are accepted. This prevents unauthorized parties from injecting events into the bridge. + +## Changes Made + +### `src/config.rs` +- Added `webhook_secret()` static method to `BridgeConfig` — reads `WEBHOOK_SECRET` env var, returns `Option`. + +### `src/webhook.rs` +- Added `WebhookQuery` struct for axum query parameter extraction. +- Added `secret: Option` field to `WebhookState`. +- Updated `handle_webhook` to validate the secret before processing: returns 401 if configured secret doesn't match. +- Updated `run_webhook_server` signature to accept `secret: Option`; logs a warning at startup if unset. +- Added 4 integration tests using `tower::ServiceExt::oneshot`: correct secret (200), wrong secret (401), missing secret (401), no secret configured (200). + +### `src/main.rs` +- Reads `WEBHOOK_SECRET` via `config::BridgeConfig::webhook_secret()`. +- Passes the secret to `webhook::run_webhook_server()`. + +### `docker-compose.yml` +- Added `WEBHOOK_SECRET=${WEBHOOK_SECRET}` to environment section. + +### `config.example.toml` +- Added comment documenting the `WEBHOOK_SECRET` env var. + +### `README.md` +- Updated webhook URL example to include `?secret=` parameter. +- Added environment variables table documenting all three secrets. + +### `Cargo.toml` +- Added `tower` (0.5, `util` feature) as dev dependency for handler tests. + +## Follow-up Items + +- None. All 65 tests pass. diff --git a/config.example.toml b/config.example.toml index 4ae2504..26a374f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,7 @@ [irc] server = "irc.zeronode.net" +# Set IRC_PASSWORD env var for server password (PASS command) +# username = "myuser" port = 6667 tls = false nick = "owncast-bridge" @@ -8,6 +10,7 @@ channel = "#BowlAfterBowl" [owncast] url = "https://owncast.bowlafterbowl.com" # Set OWNCAST_ACCESS_TOKEN env var for the token +# Set WEBHOOK_SECRET env var to require ?secret= on incoming webhooks webhook_port = 9078 websocket_enabled = true health_poll_interval_secs = 30 diff --git a/docker-compose.yml b/docker-compose.yml index 70f6373..d4490f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,10 @@ services: restart: unless-stopped environment: - OWNCAST_ACCESS_TOKEN=${OWNCAST_ACCESS_TOKEN} + - IRC_PASSWORD=${IRC_PASSWORD} + - WEBHOOK_SECRET=${WEBHOOK_SECRET} - RUST_LOG=info volumes: - - ./config.toml:/etc/owncast-irc-bridge/config.toml:ro + - ./config.toml:/etc/owncast-irc-bridge/config.toml:ro,z ports: - "9078:9078" diff --git a/src/config.rs b/src/config.rs index a203886..c433e07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,8 @@ pub struct IrcConfig { pub tls: bool, #[serde(default = "default_nick")] pub nick: String, + #[serde(default)] + pub username: Option, pub channel: String, } @@ -60,10 +62,18 @@ impl BridgeConfig { Ok(config) } + pub fn irc_server_password() -> Option { + std::env::var("IRC_PASSWORD").ok() + } + pub fn owncast_access_token(&self) -> anyhow::Result { std::env::var("OWNCAST_ACCESS_TOKEN") .map_err(|_| anyhow::anyhow!("OWNCAST_ACCESS_TOKEN env var not set")) } + + pub fn webhook_secret() -> Option { + std::env::var("WEBHOOK_SECRET").ok() + } } fn default_irc_port() -> u16 { 6667 } @@ -100,6 +110,7 @@ impl BridgeConfig { port: 6667, tls: false, nick: "test-bot".to_string(), + username: None, channel: "#test".to_string(), }, owncast: OwncastConfig { @@ -134,6 +145,7 @@ url = "https://owncast.example.com" assert_eq!(config.irc.port, 6667); assert_eq!(config.irc.tls, false); assert_eq!(config.irc.nick, "owncast-bridge"); + assert!(config.irc.username.is_none()); assert_eq!(config.irc.channel, "#test"); assert_eq!(config.owncast.url, "https://owncast.example.com"); assert_eq!(config.owncast.webhook_port, 9078); @@ -153,6 +165,7 @@ server = "irc.example.com" port = 6697 tls = true nick = "mybot" +username = "myident" channel = "#mychan" [owncast] @@ -173,12 +186,21 @@ socket_path = "/var/run/bridge.sock" assert_eq!(config.irc.port, 6697); assert!(config.irc.tls); assert_eq!(config.irc.nick, "mybot"); + assert_eq!(config.irc.username.as_deref(), Some("myident")); assert_eq!(config.owncast.webhook_port, 8888); assert!(config.owncast.websocket_enabled); assert_eq!(config.bridge.message_buffer_size, 50); assert_eq!(config.control.socket_path, "/var/run/bridge.sock"); } + #[test] + fn test_irc_server_password_from_env() { + std::env::set_var("IRC_PASSWORD", "secret123"); + assert_eq!(BridgeConfig::irc_server_password().as_deref(), Some("secret123")); + std::env::remove_var("IRC_PASSWORD"); + assert!(BridgeConfig::irc_server_password().is_none()); + } + #[test] fn test_access_token_from_env() { std::env::set_var("OWNCAST_ACCESS_TOKEN", "test-token-123"); diff --git a/src/irc_task.rs b/src/irc_task.rs index 1ba951f..7accc57 100644 --- a/src/irc_task.rs +++ b/src/irc_task.rs @@ -5,7 +5,7 @@ use irc::client::prelude::*; use tokio::sync::mpsc; use tracing::{error, info}; -use crate::config::IrcConfig; +use crate::config::{BridgeConfig, IrcConfig}; use crate::events::{BridgeEvent, Source}; pub async fn run_irc_task( @@ -52,6 +52,8 @@ async fn connect_and_run( port: Some(config.port), use_tls: Some(config.tls), channels: vec![config.channel.clone()], + password: BridgeConfig::irc_server_password(), + username: config.username.clone(), ..Config::default() }; diff --git a/src/main.rs b/src/main.rs index 6bf288a..befd117 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let config = config::BridgeConfig::load(&cli.config)?; let access_token = config.owncast_access_token()?; + let webhook_secret = config::BridgeConfig::webhook_secret(); info!("Starting owncast-irc-bridge"); @@ -64,7 +65,7 @@ async fn main() -> anyhow::Result<()> { let webhook_port = config.owncast.webhook_port; let webhook_event_tx = event_tx.clone(); let _webhook_handle = tokio::spawn(async move { - if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx).await { + if let Err(e) = webhook::run_webhook_server(webhook_port, webhook_event_tx, webhook_secret).await { tracing::error!(error = %e, "Webhook server failed"); } }); diff --git a/src/webhook.rs b/src/webhook.rs index 989a67e..7611489 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use axum::{extract::{Query, State}, http::StatusCode, routing::post, Json, Router}; use serde::Deserialize; use tokio::sync::mpsc; use tracing::{info, warn}; @@ -67,15 +67,32 @@ impl WebhookPayload { } } +#[derive(Deserialize)] +struct WebhookQuery { + secret: Option, +} + #[derive(Clone)] struct WebhookState { event_tx: mpsc::Sender, + secret: Option, } async fn handle_webhook( State(state): State, + Query(query): Query, Json(payload): Json, ) -> StatusCode { + if let Some(ref expected) = state.secret { + match query.secret { + Some(ref provided) if provided == expected => {} + _ => { + warn!("Webhook request rejected: invalid or missing secret"); + return StatusCode::UNAUTHORIZED; + } + } + } + info!(event_type = %payload.event_type, "Received webhook"); match payload.into_bridge_event() { @@ -93,8 +110,13 @@ async fn handle_webhook( pub async fn run_webhook_server( port: u16, event_tx: mpsc::Sender, + secret: Option, ) -> anyhow::Result<()> { - let state = WebhookState { event_tx }; + if secret.is_none() { + warn!("WEBHOOK_SECRET is not set — webhook endpoint accepts all requests"); + } + + let state = WebhookState { event_tx, secret }; let app = Router::new() .route("/webhook", post(handle_webhook)) .with_state(state); @@ -110,6 +132,91 @@ pub async fn run_webhook_server( #[cfg(test)] mod tests { use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn build_app(secret: Option) -> (Router, mpsc::Receiver) { + let (event_tx, rx) = mpsc::channel(16); + let state = WebhookState { event_tx, secret }; + let app = Router::new() + .route("/webhook", post(handle_webhook)) + .with_state(state); + (app, rx) + } + + fn chat_body() -> String { + serde_json::json!({ + "type": "CHAT", + "eventData": { + "user": { "displayName": "test", "isBot": false }, + "body": "hi", + "id": "msg1", + "visible": true + } + }) + .to_string() + } + + #[tokio::test] + async fn test_auth_correct_secret_returns_200() { + let (app, _rx) = build_app(Some("mysecret".to_string())); + let resp = app + .oneshot( + Request::post("/webhook?secret=mysecret") + .header("content-type", "application/json") + .body(Body::from(chat_body())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_auth_wrong_secret_returns_401() { + let (app, _rx) = build_app(Some("mysecret".to_string())); + let resp = app + .oneshot( + Request::post("/webhook?secret=wrong") + .header("content-type", "application/json") + .body(Body::from(chat_body())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_auth_missing_secret_returns_401() { + let (app, _rx) = build_app(Some("mysecret".to_string())); + let resp = app + .oneshot( + Request::post("/webhook") + .header("content-type", "application/json") + .body(Body::from(chat_body())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_auth_no_secret_configured_accepts_all() { + let (app, _rx) = build_app(None); + let resp = app + .oneshot( + Request::post("/webhook") + .header("content-type", "application/json") + .body(Body::from(chat_body())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } #[test] fn test_parse_chat_event() {