use std::io::{prelude::*, BufReader}; use std::thread::JoinHandle; use std::{env, io, net, process, thread, time}; use crossbeam_channel as chan; use cyphernet::EcSign; use netservices::tunnel::Tunnel; use netservices::{NetSession, SplitIo}; use radicle::crypto::Signer; use radicle::identity::Id; use radicle::storage::{ReadRepository, RefUpdate, WriteRepository, WriteStorage}; use radicle::{git, Storage}; use reactor::poller::popol; use crate::node::{FetchError, FetchResult}; use crate::runtime::Handle; use crate::service::reactor::Fetch; use crate::wire::{WireReader, WireSession, WireWriter}; /// Worker pool configuration. pub struct Config { /// Number of worker threads. pub capacity: usize, /// Whether to use atomic fetches. pub atomic: bool, /// Thread name. pub name: String, /// Timeout for all operations. pub timeout: time::Duration, /// Git daemon address. pub daemon: net::SocketAddr, /// Git storage. pub storage: Storage, } /// Worker request. pub struct WorkerReq { pub fetch: Fetch, pub session: WireSession, pub drain: Vec, } /// Worker response. pub struct WorkerResp { pub result: FetchResult, pub session: WireSession, } /// A worker that replicates git objects. struct Worker { storage: Storage, tasks: chan::Receiver>, daemon: net::SocketAddr, timeout: time::Duration, handle: Handle, atomic: bool, name: String, } impl Worker { /// Waits for tasks and runs them. Blocks indefinitely unless there is an error receiving /// the next task. fn run(mut self) -> Result<(), chan::RecvError> { loop { let task = self.tasks.recv()?; self.process(task); } } fn process(&mut self, task: WorkerReq) { let WorkerReq { fetch, session, drain, } = task; let (session, result) = self._process(&fetch, drain, session); let result = FetchResult { rid: fetch.repo, remote: fetch.remote, namespaces: fetch.namespaces, result, }; log::debug!(target: "worker", "Sending response back to service.."); if self .handle .worker_result(WorkerResp { result, session }) .is_err() { log::error!(target: "worker", "Unable to report fetch result: worker channel disconnected"); } } fn _process( &self, fetch: &Fetch, drain: Vec, mut session: WireSession, ) -> (WireSession, Result, FetchError>) { if fetch.initiated { log::debug!(target: "worker", "Worker processing outgoing fetch for {}", fetch.repo); let mut tunnel = match Tunnel::with(session, net::SocketAddr::from(([0, 0, 0, 0], 0))) { Ok(tunnel) => tunnel, Err((session, err)) => return (session, Err(err.into())), }; let result = self.fetch(fetch, &mut tunnel); let mut session = tunnel.into_session(); // A flush after all commands have been sent is optional; we do it because we're not // closing the connection and therefore there's no other way for the server to know // we're done sending commands. if let Err(err) = pktline::flush(&mut session) { log::error!(target: "worker", "Fetch error: {err}"); } if let Err(err) = &result { log::error!(target: "worker", "Fetch error: {err}"); } (session, result) } else { log::debug!(target: "worker", "Worker processing incoming fetch for {}", fetch.repo); if let Err(err) = session.as_connection_mut().set_nonblocking(false) { return (session, Err(err.into())); } let (mut stream_r, mut stream_w) = match session.split_io() { Ok((r, w)) => (r, w), Err(err) => { return (err.original, Err(err.error.into())); } }; let result = self.upload_pack(fetch, drain, &mut stream_r, &mut stream_w); let session = WireSession::from_split_io(stream_r, stream_w); if let Err(err) = &result { log::error!(target: "worker", "Upload-pack error: {err}"); } (session, result) } } fn fetch( &self, fetch: &Fetch, tunnel: &mut Tunnel>, ) -> Result, FetchError> { let repo = self.storage.repository(fetch.repo)?; let tunnel_addr = tunnel.local_addr()?; let mut cmd = process::Command::new("git"); cmd.current_dir(repo.path()) .env_clear() .envs(env::vars().filter(|(k, _)| k == "PATH" || k.starts_with("GIT_TRACE"))) .args(["-c", "protocol.version=2"]) .arg("fetch") .arg("--verbose"); if self.atomic { // Enable atomic fetch. Only works with Git 2.31 and later. cmd.arg("--atomic"); } cmd.arg(format!("git://{tunnel_addr}/{}", repo.id.canonical())) // FIXME: We need to omit our own namespace from this refspec in case we're fetching '*'. .arg(fetch.namespaces.as_fetchspec()) .stdout(process::Stdio::piped()) .stderr(process::Stdio::piped()) .stdin(process::Stdio::piped()); log::debug!(target: "worker", "Running command: {:?}", cmd); let mut child = cmd.spawn()?; let stderr = child.stderr.take().unwrap(); thread::Builder::new().name(self.name.clone()).spawn(|| { for line in BufReader::new(stderr).lines().flatten() { log::debug!(target: "worker", "Git: {}", line); } })?; let _ = tunnel.tunnel_once(popol::Poller::new(), self.timeout)?; // TODO: Parse fetch output to return updates. if child.wait()?.success() { log::debug!(target: "worker", "Fetch for {} exited successfully", fetch.repo); } else { log::error!(target: "worker", "Fetch for {} failed", fetch.repo); } let head = repo.set_head()?; log::debug!(target: "worker", "Head for {} set to {head}", fetch.repo); Ok(vec![]) } fn upload_pack( &self, fetch: &Fetch, drain: Vec, stream_r: &mut WireReader, stream_w: &mut WireWriter, ) -> Result, FetchError> { // Connect to our local git daemon, running as a child process. let daemon = net::TcpStream::connect_timeout(&self.daemon, self.timeout)?; let (mut daemon_r, mut daemon_w) = (daemon.try_clone().unwrap(), daemon); let mut stream_r = pktline::Reader::new(drain, stream_r); let mut daemon_r = pktline::Reader::new(vec![], &mut daemon_r); let mut buffer = [0; u16::MAX as usize + 1]; // Read the request packet line to make sure the repository being requested matches what // we expect, and that the service requested is valid. let request = match stream_r.read_request_pktline() { Ok((req, pktline)) => { log::debug!( target: "worker", "Parsed git command packet-line for {}: {:?}", fetch.repo, req ); if req.repo != fetch.repo { return Err(FetchError::Git(git::raw::Error::from_str( "git pkt-line command does not match fetch request", ))); } pktline } Err(err) => { return Err(FetchError::Git(git::raw::Error::from_str(&format!( "error parsing git command packet-line: {err}" )))); } }; // Write the raw request to the daemon, once we've verified it. daemon_w.write_all(&request)?; // We now loop, alternating between reading requests from the client, and writing responses // back from the daemon.. Requests are delimited with a flush packet (`flush-pkt`). loop { if let Err(e) = daemon_r.read_pktlines(stream_w, &mut buffer) { // This is the expected error when the remote disconnects. if e.kind() == io::ErrorKind::UnexpectedEof { break; } log::debug!(target: "worker", "Upload of {} to {} returned error: {e}", fetch.repo, fetch.remote); return Err(e.into()); } if let Err(e) = stream_r.read_pktlines(&mut daemon_w, &mut buffer) { log::debug!(target: "worker", "Remote returned error: {e}"); break; } } log::debug!(target: "worker", "Upload of {} to {} exited successfully", fetch.repo, fetch.remote); // When we aren't the one fetching, no refs are updated. Ok(vec![]) } } /// A pool of workers. One thread is allocated for each worker. pub struct WorkerPool { pool: Vec>>, } impl WorkerPool { /// Create a new worker pool with the given parameters. pub fn with( tasks: chan::Receiver>, handle: Handle, config: Config, ) -> Self { let mut pool = Vec::with_capacity(config.capacity); for _ in 0..config.capacity { let worker = Worker { tasks: tasks.clone(), handle: handle.clone(), storage: config.storage.clone(), daemon: config.daemon, timeout: config.timeout, name: config.name.clone(), atomic: config.atomic, }; let thread = thread::Builder::new() .name(config.name.clone()) .spawn(|| worker.run()) .unwrap(); pool.push(thread); } Self { pool } } /// Run the worker pool. /// /// Blocks until all worker threads have exited. pub fn run(self) -> thread::Result<()> { for (i, worker) in self.pool.into_iter().enumerate() { if let Err(err) = worker.join()? { log::debug!(target: "pool", "Worker {i} exited: {err}"); } } log::debug!(target: "pool", "Worker pool shutting down.."); Ok(()) } } mod pktline { use std::io; use std::io::Read; use std::str; use super::Id; pub const HEADER_LEN: usize = 4; pub const FLUSH_PKT: &[u8; HEADER_LEN] = b"0000"; pub const DELIM_PKT: &[u8; HEADER_LEN] = b"0001"; pub const RESPONSE_END_PKT: &[u8; HEADER_LEN] = b"0002"; pub fn flush(w: &mut W) -> io::Result<()> { write!(w, "0000") } pub struct Reader<'a, R> { drain: Vec, stream: &'a mut R, } impl<'a, R: io::Read> Reader<'a, R> { pub fn new(drain: Vec, stream: &'a mut R) -> Self { Self { drain, stream } } /// Parse a Git request packet-line. /// /// Example: `0032git-upload-pack /project.git\0host=myserver.com\0` /// pub fn read_request_pktline(&mut self) -> io::Result<(GitRequest, Vec)> { let mut pktline = [0u8; 1024]; let length = self.read_pktline(&mut pktline)?; let Some(cmd) = GitRequest::parse(&pktline[4..length]) else { return Err(io::ErrorKind::InvalidInput.into()); }; Ok((cmd, Vec::from(&pktline[..length]))) } /// Parse a Git packet-line. pub fn read_pktline(&mut self, buf: &mut [u8]) -> io::Result { self.read_exact(&mut buf[..HEADER_LEN])?; if &buf[..HEADER_LEN] == FLUSH_PKT || &buf[..HEADER_LEN] == DELIM_PKT || &buf[..HEADER_LEN] == RESPONSE_END_PKT { return Ok(HEADER_LEN); } let length = str::from_utf8(&buf[..HEADER_LEN]) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; let length = usize::from_str_radix(length, 16) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; self.read_exact(&mut buf[HEADER_LEN..length])?; Ok(length) } pub fn read_pktlines(&mut self, w: &mut W, buf: &mut [u8]) -> io::Result<()> { loop { let n = self.read_pktline(buf)?; if n == 0 { break; } w.write_all(&buf[..n])?; if &buf[..n] == FLUSH_PKT { return Ok(()); } } Ok(()) } } impl<'a, R: io::Read> io::Read for Reader<'a, R> { fn read(&mut self, buf: &mut [u8]) -> io::Result { if !self.drain.is_empty() { let count = buf.len().min(self.drain.len()); buf[..count].copy_from_slice(&self.drain[..count]); self.drain.drain(..count); return Ok(count); } self.stream.read(buf) } } #[derive(Debug)] pub struct GitRequest { pub repo: Id, pub path: String, pub host: Option<(String, Option)>, pub extra: Vec<(String, Option)>, } impl GitRequest { /// Parse a Git command from a packet-line. fn parse(input: &[u8]) -> Option { let input = str::from_utf8(input).ok()?; let mut parts = input .strip_prefix("git-upload-pack ")? .split_terminator('\0'); let path = parts.next()?.to_owned(); let repo = path.strip_prefix('/')?.parse().ok()?; let host = match parts.next() { None | Some("") => None, Some(host) => { let host = host.strip_prefix("host=")?; match host.split_once(':') { None => Some((host.to_owned(), None)), Some((host, port)) => { let port = port.parse::().ok()?; Some((host.to_owned(), Some(port))) } } } }; let extra = parts .skip_while(|part| part.is_empty()) .map(|part| match part.split_once('=') { None => (part.to_owned(), None), Some((k, v)) => (k.to_owned(), Some(v.to_owned())), }) .collect(); Some(Self { repo, path, host, extra, }) } } }