use anyhow::{Context, Result};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tracing::{debug, info, warn};
pub struct BoreTunnel {
pub process: Child,
pub host: String,
pub port: u16,
}
impl BoreTunnel {
pub async fn start(local_port: u16) -> Result<Self> {
if !is_bore_available().await {
anyhow::bail!(
"bore not found in PATH. Install it with:\n\
cargo install bore-cli\n\
\n\
Or via Homebrew:\n\
brew install bore-cli"
);
}
info!("Starting bore tunnel for port {}...", local_port);
let mut process = Command::new("bore")
.arg("local")
.arg(local_port.to_string())
.arg("--to")
.arg("bore.pub")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true) .spawn()
.context("Failed to start bore")?;
let stdout = process.stdout.take().context("Failed to get stdout")?;
let mut reader = BufReader::new(stdout).lines();
let (host, port) = tokio::time::timeout(std::time::Duration::from_secs(30), async {
while let Ok(Some(line)) = reader.next_line().await {
debug!("bore: {}", line);
if line.contains("listening at") {
if let Some((host, port)) = extract_bore_address(&line) {
return Ok((host, port));
}
}
if line.to_lowercase().contains("error") {
warn!("bore: {}", line);
}
}
anyhow::bail!("Could not find tunnel address in bore output")
})
.await
.context("Timeout waiting for bore tunnel")??;
info!("Tunnel established: {}:{}", host, port);
Ok(Self { process, host, port })
}
pub fn connection_string(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
async fn is_bore_available() -> bool {
Command::new("bore")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
fn extract_bore_address(line: &str) -> Option<(String, u16)> {
if let Some(pos) = line.find("listening at") {
let after = &line[pos + "listening at".len()..].trim();
if let Some(colon_pos) = after.rfind(':') {
let host = after[..colon_pos].trim().to_string();
if let Ok(port) = after[colon_pos + 1..].trim().parse::<u16>() {
if !host.is_empty() {
return Some((host, port));
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bore_address() {
assert_eq!(
extract_bore_address("2026-01-13T13:48:35.804069Z INFO bore_cli::client: listening at bore.pub:5819"),
Some(("bore.pub".to_string(), 5819))
);
assert_eq!(
extract_bore_address("listening at bore.pub:12345"),
Some(("bore.pub".to_string(), 12345))
);
assert_eq!(
extract_bore_address("Starting tunnel..."),
None
);
}
}