fix: HTML-escape IRC username in Owncast chat, fix WebSocket auth, fix echo suppression
- Escape angle brackets around IRC username so Owncast doesn't swallow them as HTML tags (<nick> instead of <nick>) - Register a chat user via POST /api/chat/register to obtain an accessToken, then pass it as a query param when connecting to /ws (Owncast closes the WebSocket immediately without one) - Cache the access token across reconnections; re-register only on rejection - Add ws_display_name config option (default "IRC Bridge") - Fix echo suppression: record_sent and is_echo now both compare raw body instead of mismatched formatted/raw values Made-with: Cursor
This commit is contained in:
19
chat-summaries/2026-03-10_21-00-summary.md
Normal file
19
chat-summaries/2026-03-10_21-00-summary.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Debug: IRC + WebSocket connection failures
|
||||
|
||||
## Task
|
||||
Investigated why both IRC and WebSocket connections were failing immediately after connecting, entering infinite reconnection loops with exponential backoff.
|
||||
|
||||
## Findings
|
||||
|
||||
Both issues were **external/environmental**, not code bugs:
|
||||
|
||||
1. **IRC** (`irc.zeronode.net`): Server was rejecting with `ERROR :Closing link: ... [No more connections allowed from your host via this connect class (global)]`. Too many existing connections from the same host IP.
|
||||
|
||||
2. **WebSocket** (`wss://owncast.bowlafterbowl.com/ws`): Caddy proxy successfully upgraded (101), but the Owncast backend immediately reset the connection. Owncast instance was offline.
|
||||
|
||||
## Changes Made
|
||||
None (instrumentation added for debugging was fully reverted after diagnosis).
|
||||
|
||||
## Follow-up Items
|
||||
- Consider logging the actual IRC ERROR message content instead of the generic "IRC stream ended" — would make future diagnosis faster without instrumentation.
|
||||
- Consider detecting fatal IRC errors (connection class limits, K-lines) and stopping reconnection attempts rather than continuing to hammer the server.
|
||||
29
chat-summaries/2026-03-10_21-30-summary.md
Normal file
29
chat-summaries/2026-03-10_21-30-summary.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Fix IRC-Owncast Bridge Issues
|
||||
|
||||
## Task
|
||||
Fix three issues: IRC username stripped in Owncast chat, WebSocket connection always failing, and echo suppression bug.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. HTML-escape username in IRC->Owncast messages (`src/router.rs`)
|
||||
- Changed `<username>` to `<username>` in the formatted message sent to Owncast's chat API
|
||||
- Owncast renders chat as HTML, so bare angle brackets were being interpreted as HTML tags and swallowed
|
||||
|
||||
### 2. Fix WebSocket authentication (`src/websocket.rs`, `src/owncast_api.rs`, `src/main.rs`)
|
||||
- **Root cause**: Owncast's `/ws` endpoint requires an `accessToken` query parameter from a registered chat user. The bridge was connecting without one.
|
||||
- Added `register_chat_user()` to `OwncastApiClient` -- calls `POST /api/chat/register` to get a user-level access token
|
||||
- Updated `run_websocket_task` to register a chat user, cache the token, and pass it as `?accessToken=` in the WebSocket URL
|
||||
- Token is cached across reconnections; re-registration happens only if rejected
|
||||
- Added `base_url()` accessor to `OwncastApiClient`
|
||||
|
||||
### 3. Add `ws_display_name` config field (`src/config.rs`, `config.toml`, `config.example.toml`)
|
||||
- New `ws_display_name` field on `OwncastConfig` (default: "IRC Bridge") controls the display name of the WebSocket chat user in Owncast
|
||||
|
||||
### 4. Fix echo suppression bug (`src/router.rs`)
|
||||
- `record_sent` was storing the full formatted string (`[IRC] <nick> body`) but `is_echo` compared against raw `body` -- they never matched
|
||||
- Changed `record_sent` to store the raw `body` so echo suppression actually works
|
||||
- Moved the `is_echo` check before formatting in the Owncast->IRC path to avoid unnecessary work
|
||||
|
||||
## Follow-up Items
|
||||
- Owncast admin still needs to configure webhook URL pointing to the bridge's port 9078
|
||||
- The bridge's WebSocket user will appear as "IRC Bridge" in Owncast's connected users list
|
||||
@@ -11,6 +11,7 @@ url = "https://owncast.bowlafterbowl.com"
|
||||
webhook_port = 9078
|
||||
websocket_enabled = true
|
||||
health_poll_interval_secs = 30
|
||||
ws_display_name = "IRC Bridge"
|
||||
|
||||
[bridge]
|
||||
irc_prefix = "[IRC]"
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct OwncastConfig {
|
||||
pub websocket_enabled: bool,
|
||||
#[serde(default = "default_health_poll_interval")]
|
||||
pub health_poll_interval_secs: u64,
|
||||
#[serde(default = "default_ws_display_name")]
|
||||
pub ws_display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -70,6 +72,7 @@ fn default_webhook_port() -> u16 { 9078 }
|
||||
fn default_health_poll_interval() -> u64 { 30 }
|
||||
fn default_irc_prefix() -> String { "[IRC]".to_string() }
|
||||
fn default_owncast_prefix() -> String { "[OC]".to_string() }
|
||||
fn default_ws_display_name() -> String { "IRC Bridge".to_string() }
|
||||
fn default_socket_path() -> String { "/tmp/owncast-irc-bridge.sock".to_string() }
|
||||
|
||||
impl Default for BridgeSettings {
|
||||
@@ -104,6 +107,7 @@ impl BridgeConfig {
|
||||
webhook_port: 9078,
|
||||
websocket_enabled: false,
|
||||
health_poll_interval_secs: 30,
|
||||
ws_display_name: default_ws_display_name(),
|
||||
},
|
||||
bridge: BridgeSettings::default(),
|
||||
control: ControlConfig::default(),
|
||||
|
||||
@@ -58,12 +58,44 @@ impl OwncastApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_chat_user(&self, display_name: &str) -> anyhow::Result<ChatRegistration> {
|
||||
let url = format!("{}/api/chat/register", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "displayName": display_name }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Chat registration failed ({status}): {body}");
|
||||
}
|
||||
|
||||
let reg: ChatRegistration = resp.json().await?;
|
||||
Ok(reg)
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> anyhow::Result<OwncastStatus> {
|
||||
let url = format!("{}/api/status", self.base_url);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let status: OwncastStatus = resp.json().await?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ChatRegistration {
|
||||
pub id: String,
|
||||
#[serde(rename = "accessToken")]
|
||||
pub access_token: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
||||
@@ -153,17 +153,17 @@ async fn handle_event(
|
||||
if *owncast_state == OwncastState::Unavailable {
|
||||
return;
|
||||
}
|
||||
let formatted = format!("{} <{}> {}", settings.irc_prefix, username, body);
|
||||
echo.record_sent(&formatted);
|
||||
let formatted = format!("{} <{}> {}", settings.irc_prefix, username, body);
|
||||
echo.record_sent(&body);
|
||||
if let Err(e) = api_client.send_chat_message(&formatted).await {
|
||||
warn!(error = %e, "Failed to send to Owncast");
|
||||
}
|
||||
}
|
||||
Source::Owncast => {
|
||||
let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body);
|
||||
if echo.is_echo(&body) {
|
||||
return;
|
||||
}
|
||||
let formatted = format!("{} <{}> {}", settings.owncast_prefix, username, body);
|
||||
if irc_tx.send(formatted).await.is_err() {
|
||||
warn!("IRC outbound channel closed");
|
||||
}
|
||||
|
||||
@@ -7,17 +7,33 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::events::{BridgeEvent, Source};
|
||||
use crate::html::strip_html;
|
||||
use crate::owncast_api::OwncastApiClient;
|
||||
|
||||
pub async fn run_websocket_task(
|
||||
owncast_url: String,
|
||||
api_client: OwncastApiClient,
|
||||
display_name: String,
|
||||
event_tx: mpsc::Sender<BridgeEvent>,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(60);
|
||||
let mut cached_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let ws_url = build_ws_url(&owncast_url);
|
||||
let token = match obtain_token(&api_client, &display_name, &mut cached_token).await {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
warn!("Failed to register chat user for WebSocket, retrying after backoff");
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {},
|
||||
_ = shutdown.changed() => return,
|
||||
}
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let ws_url = build_ws_url(api_client.base_url(), &token);
|
||||
info!(url = %ws_url, "Connecting to Owncast WebSocket");
|
||||
|
||||
match connect_and_listen(&ws_url, &event_tx, &mut shutdown).await {
|
||||
@@ -27,6 +43,13 @@ pub async fn run_websocket_task(
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "WebSocket connection error");
|
||||
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("403") || err_str.contains("NeedsRegistration") {
|
||||
info!("Token rejected, will re-register");
|
||||
cached_token = None;
|
||||
}
|
||||
|
||||
info!(backoff_secs = backoff.as_secs(), "Reconnecting after backoff");
|
||||
|
||||
tokio::select! {
|
||||
@@ -40,14 +63,37 @@ pub async fn run_websocket_task(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ws_url(base_url: &str) -> String {
|
||||
async fn obtain_token(
|
||||
api_client: &OwncastApiClient,
|
||||
display_name: &str,
|
||||
cached: &mut Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(ref token) = cached {
|
||||
return Some(token.clone());
|
||||
}
|
||||
|
||||
match api_client.register_chat_user(display_name).await {
|
||||
Ok(reg) => {
|
||||
info!(user_id = %reg.id, name = %reg.display_name, "Registered WebSocket chat user");
|
||||
let token = reg.access_token.clone();
|
||||
*cached = Some(token.clone());
|
||||
Some(token)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Chat user registration failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ws_url(base_url: &str, access_token: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let ws_base = if base.starts_with("https://") {
|
||||
base.replacen("https://", "wss://", 1)
|
||||
} else {
|
||||
base.replacen("http://", "ws://", 1)
|
||||
};
|
||||
format!("{}/ws", ws_base)
|
||||
format!("{}/ws?accessToken={}", ws_base, access_token)
|
||||
}
|
||||
|
||||
async fn connect_and_listen(
|
||||
@@ -115,24 +161,24 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_ws_url_https() {
|
||||
assert_eq!(
|
||||
build_ws_url("https://owncast.example.com"),
|
||||
"wss://owncast.example.com/ws"
|
||||
build_ws_url("https://owncast.example.com", "tok123"),
|
||||
"wss://owncast.example.com/ws?accessToken=tok123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ws_url_http() {
|
||||
assert_eq!(
|
||||
build_ws_url("http://localhost:8080"),
|
||||
"ws://localhost:8080/ws"
|
||||
build_ws_url("http://localhost:8080", "tok123"),
|
||||
"ws://localhost:8080/ws?accessToken=tok123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ws_url_trailing_slash() {
|
||||
assert_eq!(
|
||||
build_ws_url("https://owncast.example.com/"),
|
||||
"wss://owncast.example.com/ws"
|
||||
build_ws_url("https://owncast.example.com/", "tok123"),
|
||||
"wss://owncast.example.com/ws?accessToken=tok123"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user