use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{broadcast, mpsc, Mutex, Notify};
use super::protocol::*;
use crate::network::NetworkError;
#[derive(Clone)]
pub(crate) struct SidecarConfig {
pub binary_path: PathBuf,
pub hostname: String,
pub state_dir: String,
pub auth_key: Option<String>,
pub bridge_port: u16,
pub session_token_hex: String,
pub ephemeral: Option<bool>,
pub tags: Option<Vec<String>>,
pub idle_timeout_secs: Option<u64>,
}
impl std::fmt::Debug for SidecarConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SidecarConfig")
.field("binary_path", &self.binary_path)
.field("hostname", &self.hostname)
.field("state_dir", &self.state_dir)
.field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]"))
.field("bridge_port", &self.bridge_port)
.field("session_token_hex", &"[REDACTED]")
.field("ephemeral", &self.ephemeral)
.field("tags", &self.tags)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.finish()
}
}
#[derive(Debug, Clone)]
pub(crate) enum SidecarInternalEvent {
Started {
hostname: String,
dns_name: String,
tailscale_ip: String,
node_id: String,
protocol_version: Option<u32>,
},
Stopped,
AuthRequired { auth_url: String },
NeedsApproval,
StateChange { state: String },
KeyExpiring { expires_at: String },
HealthWarning { warnings: Vec<String> },
PeersReceived(Vec<SidecarPeer>),
PeerChanged(PeerChangedEventData),
#[allow(dead_code)]
DialSucceeded { request_id: String },
DialFailed { request_id: String, error: String },
Listening { port: u16 },
#[allow(dead_code)]
Unlistened { port: u16 },
ListeningPacket { port: u16, local_port: u16 },
PingResult(PingResultEventData),
Error { code: String, message: String },
ProxyAdded {
id: String,
listen_port: u16,
url: String,
},
ProxyRemoved { id: String },
ProxyList { proxies: Vec<ProxyInfoEventData> },
ProxyError {
id: String,
code: String,
message: String,
},
ProcessExited { exit_code: Option<i32> },
}
pub(crate) struct GoSidecar {
stdin_tx: mpsc::Sender<String>,
event_tx: broadcast::Sender<SidecarInternalEvent>,
shutdown: Arc<Notify>,
child: Arc<Mutex<Option<Child>>>,
}
impl GoSidecar {
pub async fn spawn(
config: SidecarConfig,
) -> Result<(Self, broadcast::Receiver<SidecarInternalEvent>), NetworkError> {
let (event_tx, event_rx) = broadcast::channel(256);
let (stdin_tx, stdin_rx) = mpsc::channel::<String>(64);
let shutdown = Arc::new(Notify::new());
let child = Self::spawn_child(&config)?;
let child = Arc::new(Mutex::new(Some(child)));
let sidecar = GoSidecar {
stdin_tx,
event_tx: event_tx.clone(),
shutdown: shutdown.clone(),
child: child.clone(),
};
{
let mut guard = child.lock().await;
let child_proc = guard
.as_mut()
.ok_or_else(|| NetworkError::SidecarError("child process not available".into()))?;
let stdout = child_proc
.stdout
.take()
.ok_or_else(|| NetworkError::SidecarError("failed to capture stdout".into()))?;
let stdin = child_proc
.stdin
.take()
.ok_or_else(|| NetworkError::SidecarError("failed to capture stdin".into()))?;
if let Some(stderr) = child_proc.stderr.take() {
let stderr_shutdown = shutdown.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
loop {
tokio::select! {
_ = stderr_shutdown.notified() => break,
result = reader.next_line() => {
match result {
Ok(Some(line)) => {
eprintln!("[sidecar-stderr] {line}");
}
Ok(None) => break,
Err(e) => {
tracing::warn!("sidecar stderr read error: {e}");
break;
}
}
}
}
}
});
}
Self::spawn_stdin_writer(stdin, stdin_rx, shutdown.clone());
Self::spawn_stdout_reader(stdout, event_tx.clone(), shutdown.clone());
}
Self::spawn_process_watcher(child.clone(), event_tx, shutdown);
Ok((sidecar, event_rx))
}
fn spawn_child(config: &SidecarConfig) -> Result<Child, NetworkError> {
Command::new(&config.binary_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| NetworkError::SidecarError(format!("failed to spawn: {e}")))
}
fn spawn_stdin_writer(
mut stdin: tokio::process::ChildStdin,
mut rx: mpsc::Receiver<String>,
shutdown: Arc<Notify>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown.notified() => {
tracing::debug!("sidecar stdin writer shutting down");
break;
}
msg = rx.recv() => {
match msg {
Some(line) => {
if let Err(e) = stdin.write_all(line.as_bytes()).await {
tracing::error!("failed to write to sidecar stdin: {e}");
break;
}
if let Err(e) = stdin.write_all(b"\n").await {
tracing::error!("failed to write newline to sidecar stdin: {e}");
break;
}
if let Err(e) = stdin.flush().await {
tracing::error!("failed to flush sidecar stdin: {e}");
break;
}
}
None => {
tracing::debug!("sidecar stdin channel closed");
break;
}
}
}
}
}
});
}
fn spawn_stdout_reader(
stdout: tokio::process::ChildStdout,
event_tx: broadcast::Sender<SidecarInternalEvent>,
shutdown: Arc<Notify>,
) {
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
loop {
tokio::select! {
_ = shutdown.notified() => {
tracing::debug!("sidecar stdout reader shutting down");
break;
}
result = lines.next_line() => {
match result {
Ok(Some(line)) => {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SidecarEvent>(&line) {
Ok(event) => {
if let Some(internal) = Self::map_event(event) {
let _ = event_tx.send(internal);
}
}
Err(e) => {
tracing::warn!("failed to parse sidecar event: {e}, line: {line}");
}
}
}
Ok(None) => {
tracing::info!("sidecar stdout closed (process exited)");
break;
}
Err(e) => {
tracing::error!("sidecar stdout read error: {e}");
break;
}
}
}
}
}
});
}
fn spawn_process_watcher(
child: Arc<Mutex<Option<Child>>>,
event_tx: broadcast::Sender<SidecarInternalEvent>,
shutdown: Arc<Notify>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown.notified() => {
tracing::debug!("process watcher shutting down");
return;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
let mut guard = child.lock().await;
if let Some(ref mut c) = *guard {
match c.try_wait() {
Ok(Some(status)) => {
let exit_code = status.code();
tracing::warn!("sidecar process exited with code: {exit_code:?}");
let _ = event_tx.send(SidecarInternalEvent::ProcessExited { exit_code });
*guard = None;
return;
}
Ok(None) => {
}
Err(e) => {
tracing::error!("failed to check sidecar process status: {e}");
return;
}
}
} else {
return;
}
}
}
}
});
}
fn map_event(event: SidecarEvent) -> Option<SidecarInternalEvent> {
match event.event.as_str() {
event_type::STATUS | event_type::STARTED => {
if let Ok(data) = serde_json::from_value::<StatusEventData>(event.data) {
if data.state == "running" {
Some(SidecarInternalEvent::Started {
hostname: data.hostname,
dns_name: data.dns_name,
tailscale_ip: data.tailscale_ip,
node_id: data.node_id,
protocol_version: data.protocol_version,
})
} else if data.state == "error" {
Some(SidecarInternalEvent::Error {
code: "STATUS_ERROR".to_string(),
message: data.error,
})
} else {
Some(SidecarInternalEvent::StateChange { state: data.state })
}
} else {
None
}
}
event_type::STOPPED => Some(SidecarInternalEvent::Stopped),
event_type::AUTH_REQUIRED => {
serde_json::from_value::<AuthRequiredEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::AuthRequired {
auth_url: d.auth_url,
})
}
event_type::NEEDS_APPROVAL => Some(SidecarInternalEvent::NeedsApproval),
event_type::STATE_CHANGE => serde_json::from_value::<StateChangeEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::StateChange { state: d.state }),
event_type::KEY_EXPIRING => serde_json::from_value::<KeyExpiringEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::KeyExpiring {
expires_at: d.expires_at,
}),
event_type::HEALTH_WARNING => {
serde_json::from_value::<HealthWarningEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::HealthWarning {
warnings: d.warnings,
})
}
event_type::PEERS => serde_json::from_value::<PeersEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::PeersReceived(d.peers)),
event_type::PEER_CHANGED => serde_json::from_value::<PeerChangedEventData>(event.data)
.ok()
.map(SidecarInternalEvent::PeerChanged),
event_type::DIAL_RESULT => serde_json::from_value::<DialResultEventData>(event.data)
.ok()
.map(|d| {
if d.success {
SidecarInternalEvent::DialSucceeded {
request_id: d.request_id,
}
} else {
SidecarInternalEvent::DialFailed {
request_id: d.request_id,
error: d.error,
}
}
}),
event_type::LISTENING => serde_json::from_value::<ListeningEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::Listening { port: d.port }),
event_type::UNLISTENED => serde_json::from_value::<UnlistenedEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::Unlistened { port: d.port }),
event_type::LISTENING_PACKET => {
serde_json::from_value::<ListeningPacketEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::ListeningPacket {
port: d.port,
local_port: d.local_port,
})
}
event_type::PING_RESULT => serde_json::from_value::<PingResultEventData>(event.data)
.ok()
.map(SidecarInternalEvent::PingResult),
event_type::ERROR => serde_json::from_value::<ErrorEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::Error {
code: d.code,
message: d.message,
}),
event_type::PROXY_ADDED => serde_json::from_value::<ProxyAddedEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::ProxyAdded {
id: d.id,
listen_port: d.listen_port,
url: d.url,
}),
event_type::PROXY_REMOVED => {
serde_json::from_value::<ProxyRemovedEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::ProxyRemoved { id: d.id })
}
event_type::PROXY_LIST_RESULT => {
serde_json::from_value::<ProxyListEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::ProxyList { proxies: d.proxies })
}
event_type::PROXY_ERROR => serde_json::from_value::<ProxyErrorEventData>(event.data)
.ok()
.map(|d| SidecarInternalEvent::ProxyError {
id: d.id,
code: d.code,
message: d.message,
}),
other => {
tracing::debug!("unhandled sidecar event type: {other}");
None
}
}
}
pub async fn send_command(&self, cmd: SidecarCommand) -> Result<(), NetworkError> {
let json = serde_json::to_string(&cmd)?;
self.stdin_tx
.send(json)
.await
.map_err(|e| NetworkError::SidecarError(format!("stdin channel closed: {e}")))
}
pub async fn send_start(&self, config: &SidecarConfig) -> Result<(), NetworkError> {
let data = StartCommandData {
hostname: config.hostname.clone(),
state_dir: config.state_dir.clone(),
auth_key: config.auth_key.clone(),
bridge_port: config.bridge_port,
session_token: config.session_token_hex.clone(),
ephemeral: config.ephemeral,
tags: config.tags.clone(),
idle_timeout_secs: config.idle_timeout_secs,
};
self.send_command(SidecarCommand {
command: command_type::START,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_stop(&self) -> Result<(), NetworkError> {
self.send_command(SidecarCommand {
command: command_type::STOP,
data: None,
})
.await
}
pub async fn send_get_peers(&self) -> Result<(), NetworkError> {
self.send_command(SidecarCommand {
command: command_type::GET_PEERS,
data: None,
})
.await
}
pub async fn send_dial(
&self,
request_id: String,
target: String,
port: u16,
tls: Option<bool>,
) -> Result<(), NetworkError> {
let data = DialCommandData {
request_id,
target,
port,
tls,
};
self.send_command(SidecarCommand {
command: command_type::DIAL,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_listen(&self, port: u16, tls: Option<bool>) -> Result<(), NetworkError> {
let data = ListenCommandData { port, tls };
self.send_command(SidecarCommand {
command: command_type::LISTEN,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_unlisten(&self, port: u16) -> Result<(), NetworkError> {
let data = UnlistenCommandData { port };
self.send_command(SidecarCommand {
command: command_type::UNLISTEN,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_ping(
&self,
target: String,
ping_type: Option<String>,
) -> Result<(), NetworkError> {
let data = PingCommandData { target, ping_type };
self.send_command(SidecarCommand {
command: command_type::PING,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_listen_packet(&self, port: u16) -> Result<(), NetworkError> {
let data = ListenPacketCommandData { port };
self.send_command(SidecarCommand {
command: command_type::LISTEN_PACKET,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_watch_peers(&self) -> Result<(), NetworkError> {
let data = WatchPeersCommandData { include_all: None };
self.send_command(SidecarCommand {
command: command_type::WATCH_PEERS,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_proxy_add(&self, data: ProxyAddCommandData) -> Result<(), NetworkError> {
self.send_command(SidecarCommand {
command: command_type::PROXY_ADD,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_proxy_remove(&self, id: &str) -> Result<(), NetworkError> {
let data = ProxyRemoveCommandData { id: id.to_string() };
self.send_command(SidecarCommand {
command: command_type::PROXY_REMOVE,
data: Some(serde_json::to_value(&data)?),
})
.await
}
pub async fn send_proxy_list(&self) -> Result<(), NetworkError> {
self.send_command(SidecarCommand {
command: command_type::PROXY_LIST,
data: None,
})
.await
}
pub fn subscribe(&self) -> broadcast::Receiver<SidecarInternalEvent> {
self.event_tx.subscribe()
}
pub async fn shutdown(&self) {
let _ = self.send_stop().await;
self.shutdown.notify_waiters();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let mut guard = self.child.lock().await;
if let Some(ref mut child) = *guard {
tracing::info!("force-killing sidecar process");
let _ = child.kill().await;
*guard = None;
}
}
}
impl Drop for GoSidecar {
fn drop(&mut self) {
self.shutdown.notify_waiters();
}
}
#[cfg(test)]
mod config_debug_tests {
use super::*;
#[test]
fn sidecar_config_debug_redacts_secrets() {
let config = SidecarConfig {
binary_path: PathBuf::from("/opt/sidecar"),
hostname: "truffle-demo-dev".to_string(),
state_dir: "/tmp/state".to_string(),
auth_key: Some("dummy-auth-SECRET123".to_string()),
bridge_port: 12345,
session_token_hex: "deadbeef".repeat(8),
ephemeral: None,
tags: None,
idle_timeout_secs: None,
};
let dbg = format!("{config:?}");
assert!(!dbg.contains("SECRET123"));
assert!(!dbg.contains("deadbeef"));
assert!(dbg.contains("[REDACTED]"));
}
}