git-remote-iroh 0.2.0

Git remote protocol support for https://www.iroh.computer
Documentation
use std::{path::PathBuf, str::FromStr};

use anyhow::Context;
use iroh::{Endpoint, EndpointAddr, SecretKey};
use iroh_rings::protocol::Status;
use tokio::io::{
    AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, stdin,
};
use tracing::{debug, info};

use crate::{GIT_IROH_ALPN, GitOp, auth::SINGLE_REPO_RESOURCE, keys, parse_iroh_url};

pub struct Client {
    url: String,
    key: SecretKey,
}

impl Client {
    pub fn new(config_dir: &PathBuf, git_dir: &PathBuf, url: &str) -> Result<Self, anyhow::Error> {
        let (_, key) = keys::load_or_create_key(config_dir, git_dir)?;
        info!(peer = key.public().to_z32(), "connecting as");
        Ok(Self {
            url: url.to_string(),
            key,
        })
    }

    pub async fn run<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
        self,
        input: R,
        mut output: W,
    ) -> Result<(), anyhow::Error> {
        let bufr = BufReader::new(input);
        let mut lines = bufr.lines();
        loop {
            let line = match lines.next_line().await? {
                Some(line) => line,
                None => break,
            };
            let command: Vec<&str> = line.split_whitespace().collect();
            if command.is_empty() {
                continue;
            }
            debug!(?command, "client");
            match command[0] {
                "capabilities" => self.capabilities(&mut output).await?,
                "connect" => {
                    if command.len() < 2 {
                        anyhow::bail!("connect: missing <url>");
                    }
                    self.connect(&mut output, command[1]).await?
                }
                s => anyhow::bail!("unsupported command: {:?}", s),
            }
            output.write(b"\n").await?;
        }
        Ok(())
    }

    async fn capabilities<W: AsyncWrite + Unpin>(
        &self,
        output: &mut W,
    ) -> Result<(), anyhow::Error> {
        output.write(b"connect\n").await?;
        Ok(())
    }

    async fn connect<W: AsyncWrite + Unpin>(
        &self,
        output: &mut W,
        cmd: &str,
    ) -> Result<(), anyhow::Error> {
        let remote_node_id = parse_iroh_url(self.url.as_str())?;
        let remote_op = GitOp::from_str(cmd)?;
        let ep = Endpoint::builder(iroh::endpoint::presets::N0)
            .secret_key(self.key.clone())
            .bind()
            .await?;

        ep.online().await;

        // connect to iroh URL at url
        let addr = EndpointAddr::new(remote_node_id);
        let conn = ep.connect(addr, GIT_IROH_ALPN).await?;
        let (mut send, mut recv) = conn.open_bi().await?;

        // send op request
        remote_op
            .write_request(&mut send, SINGLE_REPO_RESOURCE)
            .await?;

        // read auth response
        match Status::try_from(recv.read_u8().await?)? {
            Status::Denied => anyhow::bail!("permission denied"),
            Status::Allowed => {}
            s => anyhow::bail!("invalid status byte: {}", s),
        };

        // Send a single newline to indicate we're connecting the remote stream
        println!();

        // Connect local stdin/stdout to remote socket send/recv pair
        let mut stdin = stdin();
        let (r1, r2) = tokio::join!(
            tokio::io::copy(&mut stdin, &mut send),
            tokio::io::copy(&mut recv, output),
        );
        r1.context("stdin -> send")?;
        r2.context("recv -> stdout")?;
        ep.close().await;
        Ok(())
    }
}