feat: add Unix socket control interface with command parsing

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 21:58:38 -04:00
parent a2314ba50f
commit cc806c55e7
2 changed files with 129 additions and 0 deletions

128
src/control.rs Normal file
View File

@@ -0,0 +1,128 @@
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
use crate::events::{BridgeStatus, ControlCommand};
#[derive(Debug, PartialEq)]
enum ParsedCommand {
IrcConnect,
IrcDisconnect,
IrcReconnect,
OwncastConnect,
OwncastDisconnect,
OwncastReconnect,
Status,
Quit,
}
fn parse_command(input: &str) -> Option<ParsedCommand> {
let trimmed = input.trim().to_lowercase();
match trimmed.as_str() {
"irc connect" => Some(ParsedCommand::IrcConnect),
"irc disconnect" => Some(ParsedCommand::IrcDisconnect),
"irc reconnect" => Some(ParsedCommand::IrcReconnect),
"owncast connect" => Some(ParsedCommand::OwncastConnect),
"owncast disconnect" => Some(ParsedCommand::OwncastDisconnect),
"owncast reconnect" => Some(ParsedCommand::OwncastReconnect),
"status" => Some(ParsedCommand::Status),
"quit" => Some(ParsedCommand::Quit),
_ => None,
}
}
pub async fn run_control_socket(
socket_path: &str,
control_tx: mpsc::Sender<ControlCommand>,
) -> anyhow::Result<()> {
let _ = std::fs::remove_file(socket_path);
let listener = UnixListener::bind(socket_path)?;
info!(path = %socket_path, "Control socket listening");
loop {
let (stream, _) = listener.accept().await?;
let control_tx = control_tx.clone();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
match parse_command(&line) {
Some(ParsedCommand::Status) => {
let (reply_tx, reply_rx) = oneshot::channel();
let cmd = ControlCommand::Status { reply: reply_tx };
if control_tx.send(cmd).await.is_ok() {
if let Ok(status) = reply_rx.await {
let json = serde_json::to_string_pretty(&status).unwrap_or_default();
let _ = writer.write_all(json.as_bytes()).await;
let _ = writer.write_all(b"\n").await;
}
}
}
Some(parsed) => {
let cmd = match parsed {
ParsedCommand::IrcConnect => ControlCommand::IrcConnect,
ParsedCommand::IrcDisconnect => ControlCommand::IrcDisconnect,
ParsedCommand::IrcReconnect => ControlCommand::IrcReconnect,
ParsedCommand::OwncastConnect => ControlCommand::OwncastConnect,
ParsedCommand::OwncastDisconnect => ControlCommand::OwncastDisconnect,
ParsedCommand::OwncastReconnect => ControlCommand::OwncastReconnect,
ParsedCommand::Quit => ControlCommand::Quit,
ParsedCommand::Status => unreachable!(),
};
if control_tx.send(cmd).await.is_ok() {
let _ = writer.write_all(b"OK\n").await;
} else {
let _ = writer.write_all(b"ERROR: channel closed\n").await;
}
}
None => {
let _ = writer.write_all(b"ERROR: unknown command\n").await;
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_irc_commands() {
assert!(matches!(parse_command("irc connect"), Some(ParsedCommand::IrcConnect)));
assert!(matches!(parse_command("irc disconnect"), Some(ParsedCommand::IrcDisconnect)));
assert!(matches!(parse_command("irc reconnect"), Some(ParsedCommand::IrcReconnect)));
}
#[test]
fn test_parse_owncast_commands() {
assert!(matches!(parse_command("owncast connect"), Some(ParsedCommand::OwncastConnect)));
assert!(matches!(parse_command("owncast disconnect"), Some(ParsedCommand::OwncastDisconnect)));
assert!(matches!(parse_command("owncast reconnect"), Some(ParsedCommand::OwncastReconnect)));
}
#[test]
fn test_parse_status_and_quit() {
assert!(matches!(parse_command("status"), Some(ParsedCommand::Status)));
assert!(matches!(parse_command("quit"), Some(ParsedCommand::Quit)));
}
#[test]
fn test_parse_unknown() {
assert!(parse_command("unknown command").is_none());
assert!(parse_command("").is_none());
}
#[test]
fn test_parse_case_insensitive_trimmed() {
assert!(matches!(parse_command(" IRC CONNECT "), Some(ParsedCommand::IrcConnect)));
assert!(matches!(parse_command("QUIT\n"), Some(ParsedCommand::Quit)));
}
}

View File

@@ -1,4 +1,5 @@
mod config;
mod control;
mod events;
mod health;
mod html;