From 889f211e7126a63931961fe9c5530043e79c1fe9 Mon Sep 17 00:00:00 2001 From: Vlad Ivanov Date: Fri, 7 Oct 2022 14:28:23 +0000 Subject: [PATCH] SSH support, part 1 * add a custom shell for use with SSH * add an RPC interface for running commands from the main process * add an endpoint where git-upload-pack launch can be requested * prepare a multi-service docker image * add a userland ssh server (only for development and testing) Change: If3ace8e012c5608d5f589589b7d4931dd6a9e185 commit-id:e2721eb4 --- .dockerignore | 2 + .github/workflows/go.yml | 23 ++ Cargo.lock | 63 ++++ Cargo.toml | 3 +- josh-proxy/Cargo.toml | 4 +- josh-proxy/src/bin/josh-proxy.rs | 308 +++++++++++++++++--- josh-rpc/Cargo.toml | 10 + josh-rpc/src/calls.rs | 18 ++ josh-rpc/src/lib.rs | 3 + josh-rpc/src/named_pipe.rs | 68 +++++ josh-rpc/src/tokio_fd.rs | 134 +++++++++ josh-ssh-dev-server/.gitignore | 2 + josh-ssh-dev-server/Taskfile.yml | 22 ++ josh-ssh-dev-server/go.mod | 11 + josh-ssh-dev-server/go.sum | 22 ++ josh-ssh-dev-server/main.go | 82 ++++++ josh-ssh-shell/Cargo.toml | 20 ++ josh-ssh-shell/docker/Dockerfile | 99 +++++++ josh-ssh-shell/docker/etc/ssh/sshd_config | 47 +++ josh-ssh-shell/docker/josh-auth-key.sh | 8 + josh-ssh-shell/docker/josh-generate-keys.sh | 59 ++++ josh-ssh-shell/docker/s6-rc.d/finish | 13 + josh-ssh-shell/src/bin/josh-ssh-shell.rs | 247 ++++++++++++++++ josh-ssh-shell/src/lib.rs | 1 + 24 files changed, 1228 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/go.yml create mode 100644 josh-rpc/Cargo.toml create mode 100644 josh-rpc/src/calls.rs create mode 100644 josh-rpc/src/lib.rs create mode 100644 josh-rpc/src/named_pipe.rs create mode 100644 josh-rpc/src/tokio_fd.rs create mode 100644 josh-ssh-dev-server/.gitignore create mode 100644 josh-ssh-dev-server/Taskfile.yml create mode 100644 josh-ssh-dev-server/go.mod create mode 100644 josh-ssh-dev-server/go.sum create mode 100644 josh-ssh-dev-server/main.go create mode 100644 josh-ssh-shell/Cargo.toml create mode 100644 josh-ssh-shell/docker/Dockerfile create mode 100644 josh-ssh-shell/docker/etc/ssh/sshd_config create mode 100644 josh-ssh-shell/docker/josh-auth-key.sh create mode 100644 josh-ssh-shell/docker/josh-generate-keys.sh create mode 100644 josh-ssh-shell/docker/s6-rc.d/finish create mode 100644 josh-ssh-shell/src/bin/josh-ssh-shell.rs create mode 100644 josh-ssh-shell/src/lib.rs diff --git a/.dockerignore b/.dockerignore index b0a2ba00f..674dc2bf6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,6 @@ !src !josh-ui !josh-proxy +!josh-rpc +!josh-ssh-shell !hyper_cgi diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 000000000..e4f7014f7 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,23 @@ +name: go + +on: + push: + branches: [ master ] + pull_request: + branches: [ '**' ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-go@v3 + with: + go-version: '^1.19.2' + - name: Verify dependencies + run: cd josh-ssh-dev-server && go mod verify + - name: Build + run: cd josh-ssh-dev-server && go build diff --git a/Cargo.lock b/Cargo.lock index ac213afbe..244ac7a9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,11 +205,26 @@ checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" dependencies = [ "atty", "bitflags", + "clap_derive", "clap_lex", + "once_cell", "strsim", "termcolor", ] +[[package]] +name = "clap_derive" +version = "4.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1b0f6422af32d5da0c58e2703320f379216ee70198241c84173a8c5ac28f3" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.3.0" @@ -770,6 +785,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -1031,6 +1052,7 @@ dependencies = [ "serde_yaml", "sled", "strfmt", + "tokio-util", "toml", "tracing", "tracing-log", @@ -1052,6 +1074,7 @@ dependencies = [ "hyper_cgi", "indoc", "josh", + "josh-rpc", "juniper", "lazy_static", "opentelemetry", @@ -1063,6 +1086,7 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", + "tokio-util", "toml", "tracing", "tracing-futures", @@ -1073,6 +1097,36 @@ dependencies = [ "uuid 1.2.1", ] +[[package]] +name = "josh-rpc" +version = "0.1.0" +dependencies = [ + "libc", + "rand 0.8.5", + "serde", + "tokio", +] + +[[package]] +name = "josh-ssh-shell" +version = "0.1.0" +dependencies = [ + "clap", + "josh-rpc", + "libc", + "rand 0.8.5", + "reqwest", + "serde_json", + "shell-words", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-futures", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "josh-ui" version = "0.1.0" @@ -1851,10 +1905,12 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-tls", "ipnet", "js-sys", "log", "mime", + "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -1862,6 +1918,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tokio", + "tokio-native-tls", "tower-service", "url", "wasm-bindgen", @@ -2024,6 +2081,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook-registry" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index 9ba6f43cc..27f537912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ search = [] experimental = ["search"] [workspace] -members = ["josh-proxy", "josh-ui", ".", "hyper_cgi"] +members = ["josh-proxy", "josh-ui", "josh-ssh-shell", "josh-rpc", ".", "hyper_cgi"] [dependencies] backtrace = "0.3.66" @@ -45,6 +45,7 @@ serde_json= "1.0.87" serde_yaml = "0.9.14" sled = "0.34.7" strfmt = "0.2.2" +tokio-util = "0.7.4" toml= "0.5.9" tracing = "0.1.37" tracing-log = "0.1.3" diff --git a/josh-proxy/Cargo.toml b/josh-proxy/Cargo.toml index f92b9b07e..4d9a0b17f 100644 --- a/josh-proxy/Cargo.toml +++ b/josh-proxy/Cargo.toml @@ -22,7 +22,7 @@ hyper-staticfile = "0.9.1" hyper-tls = "0.5.0" hyper_cgi = {path = "../hyper_cgi"} indoc = "1.0.7" -josh = {path = "../"} +josh = {path = ".." } juniper = { version = "0.15.10", features = ["expose-test-schema"] } lazy_static = "1.4.0" opentelemetry = "0.18.0" @@ -42,3 +42,5 @@ tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } unindent = "0.1.10" url = "2.3.1" uuid = { version = "1.2.1", features = ["v4"] } +josh-rpc = { path = "../josh-rpc" } +tokio-util = "0.7.4" diff --git a/josh-proxy/src/bin/josh-proxy.rs b/josh-proxy/src/bin/josh-proxy.rs index dbda999bf..33976d53d 100644 --- a/josh-proxy/src/bin/josh-proxy.rs +++ b/josh-proxy/src/bin/josh-proxy.rs @@ -2,7 +2,7 @@ #[macro_use] extern crate lazy_static; -use josh_proxy::RepoUpdate; +use josh_proxy::{MetaConfig, RepoUpdate}; use opentelemetry::global; use opentelemetry::sdk::propagation::TraceContextPropagator; use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -10,17 +10,22 @@ use tracing_subscriber::Layer; use futures::future; use futures::FutureExt; +use hyper::body::HttpBody; use hyper::service::{make_service_fn, service_fn}; use hyper::{Request, Response, Server, StatusCode}; use hyper_reverse_proxy; use indoc::formatdoc; -use josh::JoshError; +use josh::{josh_error, JoshError}; +use josh_rpc::calls::RequestedCommand; +use josh_rpc::tokio_fd::IntoAsyncFd; use std::collections::HashMap; use std::net::IpAddr; +use std::process::Stdio; use std::str::FromStr; use std::sync::{Arc, RwLock}; +use tokio::io::AsyncWriteExt; use tokio::process::Command; -use tracing::Span; +use tracing::{trace, Span}; use tracing_futures::Instrument; fn version_str() -> String { @@ -461,6 +466,231 @@ async fn query_meta_repo( return Ok(meta); } +async fn make_meta_config( + serv: Arc, + auth: &josh_proxy::auth::Handle, + parsed_url: &FilteredRepoUrl, +) -> josh::JoshResult { + let mut meta = Default::default(); + + if let Ok(meta_repo) = std::env::var("JOSH_META_REPO") { + let auth = if let Ok(token) = std::env::var("JOSH_META_AUTH_TOKEN") { + josh_proxy::auth::add_auth(&token)? + } else { + auth.clone() + }; + + meta = query_meta_repo(serv.clone(), &meta_repo, &parsed_url.upstream_repo, &auth).await?; + } else { + meta.config.repo = parsed_url.upstream_repo.clone(); + } + + Ok(meta) +} + +async fn serve_namespace(params: josh_rpc::calls::ServeNamespace) -> josh::JoshResult<()> { + const SERVE_TIMEOUT: u64 = 60; + + tracing::trace!("serve_namespace: {:?}", params); + + enum ServeError { + FifoError(std::io::Error), + SubprocessError(std::io::Error), + SubprocessTimeout(tokio::time::error::Elapsed), + SubprocessExited(i32), + } + + let command = match params.command { + RequestedCommand::GitUploadPack => "git-upload-pack", + RequestedCommand::GitUploadArchive => "git-upload-archive", + RequestedCommand::GitReceivePack => "git-receive-pack", + }; + + let mut process = tokio::process::Command::new(command) + .arg("") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + let stdout = process.stdout.take().ok_or(josh_error("no stdout"))?; + let stdin = process.stdin.take().ok_or(josh_error("no stdin"))?; + + let stdin_cancel_token = tokio_util::sync::CancellationToken::new(); + let stdin_cancel_token_stdout = stdin_cancel_token.clone(); + + let read_stdout = async { + // If stdout stream was closed, cancel stdin copy future + let _guard_stdin = stdin_cancel_token_stdout.drop_guard(); + + let copy_future = async { + // Move stdout here because it should be closed after copy, + // and to be closed it needs to be dropped + let mut stdout = stdout; + + // Dropping the handle at the end of this block will generate EOF at the other end + let mut stdout_pipe_handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(¶ms.stdout_pipe)? + .into_async_fd()?; + + tokio::io::copy(&mut stdout, &mut stdout_pipe_handle).await?; + stdout_pipe_handle.flush().await + }; + + copy_future.await.map_err(|e| ServeError::FifoError(e)) + }; + + let write_stdin = async { + // When stdout copying was finished (subprocess closed their + // stdout due to termination), we need to cancel this future. + // Future cancelling is implemented via token. + let copy_future = async { + // See comment about stdout above + let mut stdin = stdin; + + let mut stdin_pipe_handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(¶ms.stdin_pipe)? + .into_async_fd()?; + + tokio::io::copy(&mut stdin_pipe_handle, &mut stdin).await?; + + // Flushing is necessary to ensure file handle is closed when + // it goes out of scope / dropped + stdin.flush().await + }; + + tokio::select! { + copy_result = copy_future => { + copy_result + .map(|_| ()) + .map_err(|e| ServeError::FifoError(e)) + } + _ = stdin_cancel_token.cancelled() => { + Ok(()) + } + } + }; + + let maybe_process_completion = async { + let max_duration = tokio::time::Duration::from_secs(SERVE_TIMEOUT); + match tokio::time::timeout(max_duration, process.wait()).await { + Ok(status) => match status { + Ok(status) => match status.code() { + Some(code) if code == 0 => Ok(()), + Some(code) => Err(ServeError::SubprocessExited(code)), + None => { + let io_error = std::io::Error::from(std::io::ErrorKind::Other); + Err(ServeError::SubprocessError(io_error)) + } + }, + Err(io_error) => Err(ServeError::SubprocessError(io_error)), + }, + Err(elapsed) => Err(ServeError::SubprocessTimeout(elapsed)), + } + }; + + let subprocess_result = tokio::try_join!(read_stdout, write_stdin, maybe_process_completion); + + match subprocess_result { + Ok(_) => Ok(()), + Err(e) => match e { + ServeError::SubprocessExited(code) => Err(josh_error(&format!( + "git subprocess exited with code {}", + code + ))), + ServeError::SubprocessError(io_error) => Err(josh_error(&format!( + "could not start git subprocess: {}", + io_error + ))), + ServeError::SubprocessTimeout(elapsed) => { + let _ = process.kill().await; + Err(josh_error(&format!( + "git subprocess timed out after {}", + elapsed + ))) + } + ServeError::FifoError(io_error) => { + let _ = process.kill().await; + Err(josh_error(&format!( + "git subprocess communication error: {}", + io_error + ))) + } + }, + } +} + +fn is_repo_blocked(meta: &MetaConfig) -> bool { + let block = std::env::var("JOSH_REPO_BLOCK").unwrap_or("".to_owned()); + let block = block.split(";").collect::>(); + + for b in block { + if b == meta.config.repo { + return true; + } + } + + false +} + +async fn handle_serve_namespace_request( + req: Request, +) -> josh::JoshResult> { + let error_response = |status: StatusCode| Ok(make_response(hyper::Body::empty(), status)); + + if req.method() != hyper::Method::POST { + return error_response(StatusCode::METHOD_NOT_ALLOWED); + } + + match req.headers().get(hyper::header::CONTENT_TYPE) { + Some(value) if value == "application/json" => (), + _ => return error_response(StatusCode::BAD_REQUEST), + } + + let body = match req.into_body().data().await { + None => return error_response(StatusCode::BAD_REQUEST), + Some(result) => match result { + Ok(bytes) => bytes, + Err(_) => return error_response(StatusCode::IM_A_TEAPOT), + }, + }; + + let params = match serde_json::from_slice::(&body) { + Err(error) => { + return Ok(make_response( + hyper::Body::from(error.to_string()), + StatusCode::BAD_REQUEST, + )) + } + Ok(parsed) => parsed, + }; + + let _parsed_url = if let Some(mut parsed_url) = FilteredRepoUrl::from_str(¶ms.query) { + if parsed_url.filter_spec.is_empty() { + parsed_url.filter_spec = ":/".to_string(); + } + + parsed_url + } else { + return Ok(make_response( + hyper::Body::from("unable to parse query"), + StatusCode::BAD_REQUEST, + )); + }; + + let serve_result = serve_namespace(params).await; + tracing::trace!("serve_result: {:?}", serve_result); + + return Ok(make_response( + hyper::Body::from("handled serve_namespace request"), + StatusCode::OK, + )); +} + +// Entry point for fake git-upload-pack, git-receive-pack #[tracing::instrument] async fn call_service( serv: Arc, @@ -484,10 +714,16 @@ async fn call_service( return Ok(response); } + // When exposed to internet, should be blocked if path == "/repo_update" { return repo_update_fn(serv, req).await; } + if path == "/serve_namespace" { + return handle_serve_namespace_request(req).await; + } + + // Need to have some way of passing the filter (via remote path like what github does?) let parsed_url = { if let Some(parsed_url) = FilteredRepoUrl::from_str(&path) { let mut pu = parsed_url; @@ -530,24 +766,14 @@ async fn call_service( } }; - let mut meta = Default::default(); - - if let Ok(meta_repo) = std::env::var("JOSH_META_REPO") { - let auth = if let Ok(token) = std::env::var("JOSH_META_AUTH_TOKEN") { - josh_proxy::auth::add_auth(&token)? - } else { - auth.clone() - }; - meta = query_meta_repo(serv.clone(), &meta_repo, &parsed_url.upstream_repo, &auth).await?; - } else { - meta.config.repo = parsed_url.upstream_repo; - } + let meta = make_meta_config(serv.clone(), &auth, &parsed_url).await?; let mut filter = josh::filter::chain( meta.config.filter, josh::filter::parse(&parsed_url.filter_spec)?, ); - let remote_url = [serv.upstream_url.as_str(), meta.config.repo.as_str()].join(""); + + let remote_url = serv.upstream_url.clone() + meta.config.repo.as_str(); if let Some(filter_prefix) = ARGS.get_one::("filter-prefix").map(|v| v.as_str()) { filter = josh::filter::chain(josh::filter::parse(filter_prefix)?, filter); @@ -555,7 +781,7 @@ async fn call_service( if parsed_url.pathinfo.starts_with("/info/lfs") { return Ok(Response::builder() - .status(307) + .status(hyper::StatusCode::TEMPORARY_REDIRECT) .header("Location", format!("{}{}", remote_url, parsed_url.pathinfo)) .body(hyper::Body::empty())?); } @@ -565,35 +791,32 @@ async fn call_service( headref = "HEAD".to_string(); } - if !josh_proxy::auth::check_auth( - &remote_url, - &auth, - ARGS.get_flag("require-auth") && parsed_url.pathinfo == "/git-receive-pack", - ) - .in_current_span() - .await? + let http_auth_required = + ARGS.get_flag("require-auth") && parsed_url.pathinfo == "/git-receive-pack"; + + if !josh_proxy::auth::check_auth(&remote_url, &auth, http_auth_required) + .in_current_span() + .await? { tracing::trace!("require-auth"); let builder = Response::builder() - .header("WWW-Authenticate", "Basic realm=User Visible Realm") + .header( + hyper::header::WWW_AUTHENTICATE, + "Basic realm=User Visible Realm", + ) .status(hyper::StatusCode::UNAUTHORIZED); return Ok(builder.body(hyper::Body::empty())?); } - let block = std::env::var("JOSH_REPO_BLOCK").unwrap_or("".to_owned()); - let block = block.split(";").collect::>(); - - for b in block { - if b == meta.config.repo { - return Ok(make_response( - hyper::Body::from(formatdoc!( - r#" + if is_repo_blocked(&meta) { + return Ok(make_response( + hyper::Body::from(formatdoc!( + r#" Access to this repo is blocked via JOSH_REPO_BLOCK "# - )), - hyper::StatusCode::UNPROCESSABLE_ENTITY, - )); - } + )), + hyper::StatusCode::UNPROCESSABLE_ENTITY, + )); } if parsed_url.api == "/~/graphql" { @@ -609,6 +832,7 @@ async fn call_service( .await??); } + // fetch upstream happened when we checked for auth match fetch_upstream( serv.clone(), meta.config.repo.to_owned(), @@ -623,7 +847,10 @@ async fn call_service( Ok(res) => { if !res { let builder = Response::builder() - .header("WWW-Authenticate", "Basic realm=User Visible Realm") + .header( + hyper::header::WWW_AUTHENTICATE, + "Basic realm=User Visible Realm", + ) .status(hyper::StatusCode::UNAUTHORIZED); return Ok(builder.body(hyper::Body::empty())?); } @@ -1138,7 +1365,10 @@ async fn serve_graphql( Ok(res) => { if !res { let builder = Response::builder() - .header("WWW-Authenticate", "Basic realm=User Visible Realm") + .header( + hyper::header::WWW_AUTHENTICATE, + "Basic realm=User Visible Realm", + ) .status(hyper::StatusCode::UNAUTHORIZED); return Ok(builder.body(hyper::Body::empty())?); } diff --git a/josh-rpc/Cargo.toml b/josh-rpc/Cargo.toml new file mode 100644 index 000000000..3827b8a09 --- /dev/null +++ b/josh-rpc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "josh-rpc" +version = "0.1.0" +edition = "2018" + +[dependencies] +serde = { version = "1.0.145", features = ["std", "derive"] } +tokio = { version = "1.21.2", features = ["fs", "io-std"] } +libc = "0.2.134" +rand = "0.8.5" diff --git a/josh-rpc/src/calls.rs b/josh-rpc/src/calls.rs new file mode 100644 index 000000000..6d9f160f0 --- /dev/null +++ b/josh-rpc/src/calls.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, Debug)] +pub enum RequestedCommand { + GitUploadPack, + GitUploadArchive, + GitReceivePack, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ServeNamespace { + pub command: RequestedCommand, + pub stdin_pipe: PathBuf, + pub stdout_pipe: PathBuf, + pub ssh_socket: PathBuf, + pub query: String, +} diff --git a/josh-rpc/src/lib.rs b/josh-rpc/src/lib.rs new file mode 100644 index 000000000..3565d96ac --- /dev/null +++ b/josh-rpc/src/lib.rs @@ -0,0 +1,3 @@ +pub mod calls; +pub mod named_pipe; +pub mod tokio_fd; diff --git a/josh-rpc/src/named_pipe.rs b/josh-rpc/src/named_pipe.rs new file mode 100644 index 000000000..b8a802293 --- /dev/null +++ b/josh-rpc/src/named_pipe.rs @@ -0,0 +1,68 @@ +extern crate libc; +extern crate rand; + +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use std::ffi::CString; +use std::io::Error; +use std::path::{Path, PathBuf}; +use std::{env, io}; + +const TEMP_SUFFIX_LENGTH: usize = 32; +const PIPE_CREATE_ATTEMPTS: usize = 10; +const PIPE_FILEMODE: libc::mode_t = 0o660; + +pub struct NamedPipe { + pub path: PathBuf, +} + +impl Drop for NamedPipe { + fn drop(&mut self) { + std::fs::remove_file(&self.path).unwrap(); + } +} + +impl NamedPipe { + pub fn new(prefix: &str) -> Result { + let created_pipe = try_make_pipe(prefix)?; + Ok(NamedPipe { path: created_pipe }) + } +} + +fn make_fifo(path: &Path) -> Result<(), io::Error> { + let path_str = path.to_str().unwrap(); + let path = CString::new(path_str).unwrap(); + let return_code = unsafe { libc::mkfifo(path.as_ptr(), PIPE_FILEMODE) }; + + match return_code { + 0 => Ok(()), + _ => Err(Error::last_os_error()), + } +} + +fn make_random_path(prefix: &str) -> PathBuf { + let temp_path = env::temp_dir(); + let rand_string: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(TEMP_SUFFIX_LENGTH) + .map(char::from) + .collect(); + + let fifo_name = format!("{}-{}", prefix, rand_string); + temp_path.join(fifo_name) +} + +fn try_make_pipe(prefix: &str) -> Result { + for _ in 0..PIPE_CREATE_ATTEMPTS { + let pipe_path = make_random_path(prefix); + match make_fifo(pipe_path.as_path()) { + Ok(_) => return Ok(pipe_path), + Err(e) => match e.kind() { + io::ErrorKind::AlreadyExists => continue, + _ => (), + }, + } + } + + Err(io::Error::from(io::ErrorKind::AlreadyExists)) +} diff --git a/josh-rpc/src/tokio_fd.rs b/josh-rpc/src/tokio_fd.rs new file mode 100644 index 000000000..124ce422d --- /dev/null +++ b/josh-rpc/src/tokio_fd.rs @@ -0,0 +1,134 @@ +// Code adapted from +// https://github.com/nanpuyue/tokio-fd/blob/b4730113ca937152c2b106bb490c7b242aec2c81/src/lib.rs +// (Apache 2.0, MIT) + +use std::convert::TryFrom; +use std::fs::File; +use std::os::unix::io::{AsRawFd, IntoRawFd}; +use std::pin::Pin; +use std::task::{Context, Poll, Poll::*}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +type UnixRawFd = std::os::unix::io::RawFd; + +pub struct AsyncFd { + raw_fd: tokio::io::unix::AsyncFd, +} + +pub trait IntoAsyncFd { + fn into_async_fd(self) -> std::io::Result; +} + +impl TryFrom for AsyncFd { + type Error = std::io::Error; + + fn try_from(fd: UnixRawFd) -> std::io::Result { + fd_set_nonblock(fd)?; + + Ok(Self { + raw_fd: tokio::io::unix::AsyncFd::new(fd)?, + }) + } +} + +impl IntoAsyncFd for File { + fn into_async_fd(self) -> std::io::Result { + AsyncFd::try_from(self.into_raw_fd()) + } +} + +impl AsRawFd for AsyncFd { + fn as_raw_fd(&self) -> UnixRawFd { + *self.raw_fd.get_ref() + } +} + +impl AsyncRead for AsyncFd { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + let mut ready = match self.raw_fd.poll_read_ready(cx) { + Ready(result) => result?, + Pending => return Pending, + }; + + let ret = unsafe { + libc::read( + self.as_raw_fd(), + buf.unfilled_mut() as *mut _ as _, + buf.remaining(), + ) + }; + + return if ret < 0 { + let e = std::io::Error::last_os_error(); + if e.kind() == std::io::ErrorKind::WouldBlock { + ready.clear_ready(); + continue; + } else { + Ready(Err(e)) + } + } else { + let n = ret as usize; + unsafe { buf.assume_init(n) }; + buf.advance(n); + Ready(Ok(())) + }; + } + } +} + +impl AsyncWrite for AsyncFd { + fn poll_write( + self: Pin<&mut Self>, + ctx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + loop { + let mut ready = match self.raw_fd.poll_write_ready(ctx) { + Ready(result) => result?, + Pending => return Pending, + }; + + let ret = unsafe { libc::write(self.as_raw_fd(), buf.as_ptr() as _, buf.len()) }; + + match ret { + _ if ret < 0 => match std::io::Error::last_os_error() { + e if e.kind() == std::io::ErrorKind::WouldBlock => { + ready.clear_ready(); + continue; + } + e => return Ready(Err(e)), + }, + _ => return Ready(Ok(ret as usize)), + } + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Ready(Ok(())) + } +} + +fn fd_set_nonblock(fd: UnixRawFd) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + + match flags { + error if error < 0 => Err(std::io::Error::last_os_error()), + flags => { + let set_result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + + match set_result { + 0 => Ok(()), + _ => Err(std::io::Error::last_os_error()), + } + } + } +} diff --git a/josh-ssh-dev-server/.gitignore b/josh-ssh-dev-server/.gitignore new file mode 100644 index 000000000..8b9e5953f --- /dev/null +++ b/josh-ssh-dev-server/.gitignore @@ -0,0 +1,2 @@ +target +.task diff --git a/josh-ssh-dev-server/Taskfile.yml b/josh-ssh-dev-server/Taskfile.yml new file mode 100644 index 000000000..e8acf6ea5 --- /dev/null +++ b/josh-ssh-dev-server/Taskfile.yml @@ -0,0 +1,22 @@ +version: 3 + +tasks: + create-target-dir: + run: once + status: + - test -d target + cmds: + - mkdir target + + build: + deps: + - create-target-dir + sources: + - main.go + generates: + - target/josh-ssh-dev-server + cmds: + - go build -o target/josh-ssh-dev-server + + default: + - task: build diff --git a/josh-ssh-dev-server/go.mod b/josh-ssh-dev-server/go.mod new file mode 100644 index 000000000..b7207b72e --- /dev/null +++ b/josh-ssh-dev-server/go.mod @@ -0,0 +1,11 @@ +module josh-project/josh/ssh-dev-server + +go 1.19 + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/creack/pty v1.1.18 // indirect + github.com/gliderlabs/ssh v0.3.5 // indirect + golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect + golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect +) diff --git a/josh-ssh-dev-server/go.sum b/josh-ssh-dev-server/go.sum new file mode 100644 index 000000000..73f1b29c5 --- /dev/null +++ b/josh-ssh-dev-server/go.sum @@ -0,0 +1,22 @@ +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo= +golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM= +golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/josh-ssh-dev-server/main.go b/josh-ssh-dev-server/main.go new file mode 100644 index 000000000..2a04ae005 --- /dev/null +++ b/josh-ssh-dev-server/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "flag" + "fmt" + "github.com/gliderlabs/ssh" + "io" + "log" + "os" + "os/exec" + "sync" +) + +const DefaultServerPort = 23186 + +var CurrentTask = 1 +var TaskMutex sync.Mutex + +func runServer(port uint, shell string) { + ssh.Handle(func(session ssh.Session) { + if len(session.Command()) == 0 { + _, _ = io.WriteString(session, "Interactive invocation is not supported\n") + _ = session.Exit(1) + return + } + + cmd := exec.Command(shell, "-c", session.RawCommand()) + + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + err := cmd.Start() + if err != nil { + _, _ = io.WriteString(session.Stderr(), err.Error()+"\n") + return + } + + TaskMutex.Lock() + CurrentTask = CurrentTask + 1 + taskId := CurrentTask + TaskMutex.Unlock() + + log.Printf("started subprocess with task_id %d\n", taskId) + + go func() { + _, err := io.Copy(stdin, session) + if err != nil { + return + } + }() + + go func() { + _, err := io.Copy(os.Stderr, stderr) + if err != nil { + return + } + }() + + _, err = io.Copy(session, stdout) + _ = cmd.Wait() + log.Printf("subprocess with task_id %d exited\n", taskId) + + _ = session.Exit(0) + }) + + log.Printf("starting ssh server on port %d...\n", port) + log.Fatal(ssh.ListenAndServe(fmt.Sprintf(":%d", port), nil)) +} + +func main() { + shellPath, err := exec.LookPath("sh") + if err != nil { + log.Println("Could not find default shell (sh) executable") + os.Exit(1) + } + + port := flag.Uint("port", DefaultServerPort, "Port to listen on") + shell := flag.String("shell", shellPath, "Shell to use for commands") + flag.Parse() + runServer(*port, *shell) +} diff --git a/josh-ssh-shell/Cargo.toml b/josh-ssh-shell/Cargo.toml new file mode 100644 index 000000000..8d5aa839e --- /dev/null +++ b/josh-ssh-shell/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "josh-ssh-shell" +version = "0.1.0" +edition = "2018" + +[dependencies] +clap = { version = "4.0.9", features = ["derive", "help", "std", "usage"], default-features = false } +libc = "0.2.134" +rand = "0.8.5" +shell-words = "1.1.0" +tokio = { version = "1.21.2", features = ["fs", "rt-multi-thread", "macros", "io-std", "io-util", "net"] } +reqwest = { version = "0.11.12", features = ["json"] } +josh-rpc = { path = "../josh-rpc" } +serde_json = "1.0.86" +tracing = { version = "0.1.36", features = ["max_level_trace", "release_max_level_trace"] } +tracing-futures = "0.2.5" +tracing-opentelemetry = "0.18.0" +tracing-subscriber = { version = "0.3.15", features = ["env-filter"] } +tokio-util = "0.7.4" +thiserror = "1.0.37" diff --git a/josh-ssh-shell/docker/Dockerfile b/josh-ssh-shell/docker/Dockerfile new file mode 100644 index 000000000..48918e128 --- /dev/null +++ b/josh-ssh-shell/docker/Dockerfile @@ -0,0 +1,99 @@ +# syntax = docker/dockerfile:1.4-labs + +FROM alpine:3.16 + +RUN apk add --no-cache openssh bash git xz tree shadow + +ARG S6_OVERLAY_VERSION=3.1.2.1 +ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp +RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz +ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp +RUN tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz + +ARG GIT_GID_UID=2001 + +RUN addgroup -g ${GIT_GID_UID} git +RUN adduser \ + -h /home/git \ + -s josh-ssh-shell \ + -G git \ + -D \ + -u ${GIT_GID_UID} \ + git + +# sshd will call josh-ssh-shell -c "git command" + +# https://unix.stackexchange.com/a/193131/336647 +RUN usermod -p '*' git + +COPY josh-auth-key.sh /opt/scripts/ +COPY etc/ssh/sshd_config /etc/ssh/ + +ARG RC6_D=/etc/s6-overlay/s6-rc.d + +# s6: josh-generate-keys + +COPY josh-generate-keys.sh /opt/scripts/ + +WORKDIR ${RC6_D}/josh-generate-keys + +COPY <&1 echo "Persistent volume not mounted" + exit 1 + fi + + _ensure_dir ${KEY_DIR} + _ensure_owner ${KEY_DIR} git:git + _ensure_mode ${KEY_DIR} 700 + + if { + [[ ! -f ${KEY_DIR}/id_${KEY_TYPE} ]] || [[ ! -f ${KEY_DIR}/id_${KEY_TYPE}.pub ]] + }; then + 2>&1 echo "Generating SSH server key" + ssh-keygen -t ${KEY_TYPE} -N "" -f ${KEY_DIR}/id_${KEY_TYPE} -C git + fi + + _ensure_owner ${KEY_DIR}/id_${KEY_TYPE} git:git + _ensure_mode ${KEY_DIR}/id_${KEY_TYPE} 600 + + _ensure_owner ${KEY_DIR}/id_${KEY_TYPE}.pub git:git + _ensure_mode ${KEY_DIR}/id_${KEY_TYPE}.pub 644 +} + +_create_keys diff --git a/josh-ssh-shell/docker/s6-rc.d/finish b/josh-ssh-shell/docker/s6-rc.d/finish new file mode 100644 index 000000000..b6303aafc --- /dev/null +++ b/josh-ssh-shell/docker/s6-rc.d/finish @@ -0,0 +1,13 @@ +#!/command/execlineb -S0 + +# If one of our services dies unexpectedly, the whole container should die. +# If we exited with a nonzero code +if { s6-test $# -ne 0 } +# 256 means we were killed by a signal, eg from s6-svc +if { s6-test ${1} -ne 256 } +# Exit all other services, kill the container +foreground +{ + echo "Service terminated: exiting container" +} +/run/s6/basedir/bin/halt diff --git a/josh-ssh-shell/src/bin/josh-ssh-shell.rs b/josh-ssh-shell/src/bin/josh-ssh-shell.rs new file mode 100644 index 000000000..18ae5ba11 --- /dev/null +++ b/josh-ssh-shell/src/bin/josh-ssh-shell.rs @@ -0,0 +1,247 @@ +extern crate clap; +extern crate josh_ssh_shell; +extern crate libc; +extern crate shell_words; + +use clap::Parser; +use josh_rpc::calls::{RequestedCommand, ServeNamespace}; +use josh_rpc::named_pipe::NamedPipe; +use josh_rpc::tokio_fd::IntoAsyncFd; +use reqwest::header::CONTENT_TYPE; +use reqwest::StatusCode; +use std::convert::TryFrom; +use std::fmt::{Display, Formatter}; +use std::os::unix::fs::FileTypeExt; +use std::path::Path; +use std::process::ExitCode; +use std::time::Duration; +use std::{env, fs, process}; +use tokio::io::AsyncWriteExt; +use tracing_subscriber::Layer; + +#[derive(Parser, Debug)] +#[command(about = "Josh SSH shell")] +struct Args { + #[arg(short)] + command: String, +} + +const HTTP_REQUEST_TIMEOUT: u64 = 120; +const HTTP_JOSH_SERVER: &str = "http://localhost:8000"; + +fn die(message: &str) -> ! { + eprintln!("josh-ssh-shell: {}", message); + process::exit(1); +} + +#[derive(thiserror::Error, Debug)] +enum CallError { + FifoError(#[from] std::io::Error), + RequestError(#[from] reqwest::Error), + RemoteError { status: StatusCode, body: Vec }, +} + +impl Display for CallError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CallError::FifoError(e) => { + write!(f, "{:?}", e) + } + CallError::RequestError(e) => { + write!(f, "{:?}", e) + } + CallError::RemoteError { status, body } => { + write!(f, "Remote backend returned error: ")?; + write!(f, "status code: {}, ", status)?; + write!(f, "body: {}", String::from_utf8_lossy(body).into_owned()) + } + } + } +} + +async fn handle_command( + command: RequestedCommand, + ssh_socket: &Path, + query: &str, +) -> Result<(), CallError> { + let stdout_pipe = NamedPipe::new("josh-stdout")?; + let stdin_pipe = NamedPipe::new("josh-stdin")?; + + let stdin_cancel_token = tokio_util::sync::CancellationToken::new(); + let stdin_cancel_token_stdout = stdin_cancel_token.clone(); + let stdin_cancel_token_http = stdin_cancel_token.clone(); + + let stdout_cancel_token = tokio_util::sync::CancellationToken::new(); + let stdout_cancel_token_http = stdout_cancel_token.clone(); + + let rpc_payload = ServeNamespace { + command, + stdout_pipe: stdout_pipe.path.clone(), + stdin_pipe: stdin_pipe.path.clone(), + ssh_socket: ssh_socket.to_path_buf(), + query: query.to_string(), + }; + + let read_stdout = async move { + let _guard_stdin = stdin_cancel_token_stdout.drop_guard(); + + let copy_future = async { + let mut stdout = tokio::io::stdout(); + let mut stdout_pipe_handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(stdout_pipe.path.as_path())? + .into_async_fd()?; + + tokio::io::copy(&mut stdout_pipe_handle, &mut stdout).await?; + + Ok(()) + }; + + tokio::select! { + copy_result = copy_future => { + copy_result.map(|_| ()) + } + _ = stdout_cancel_token.cancelled() => { + Ok(()) + } + } + }; + + let write_stdin = async move { + let copy_future = async { + // When the remote end sends EOF over the stdout_pipe, + // we should stop copying stuff here + let mut stdin = josh_rpc::tokio_fd::AsyncFd::try_from(libc::STDIN_FILENO)?; + let mut stdin_pipe_handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(stdin_pipe.path.as_path())? + .into_async_fd()?; + + tokio::io::copy(&mut stdin, &mut stdin_pipe_handle).await?; + stdin_pipe_handle.flush().await?; + + Ok(()) + }; + + tokio::select! { + copy_result = copy_future => { + copy_result.map(|_| ()) + } + _ = stdin_cancel_token.cancelled() => { + Ok(()) + } + } + }; + + let make_request = async move { + let _guard_stdin = stdin_cancel_token_http.drop_guard(); + let _guard_stdout = stdout_cancel_token_http.drop_guard(); + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/serve_namespace", HTTP_JOSH_SERVER)) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&rpc_payload).unwrap()) + .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT)) + .send() + .await?; + + let status = response.status(); + let bytes = response.bytes().await?; + + match status { + StatusCode::OK | StatusCode::NO_CONTENT => Ok(()), + code => Err(CallError::RemoteError { + status: code, + body: bytes.to_vec(), + }), + } + }; + + tokio::try_join!(read_stdout, write_stdin, make_request).map(|_| ()) +} + +fn setup_tracing() { + let fmt_layer = tracing_subscriber::fmt::layer().compact().with_ansi(false); + + let filter = match env::var("RUST_LOG") { + Ok(_) => tracing_subscriber::EnvFilter::from_default_env(), + _ => tracing_subscriber::EnvFilter::new("josh_ssh_shell=trace"), + }; + + let subscriber = filter + .and_then(fmt_layer) + .with_subscriber(tracing_subscriber::Registry::default()); + + tracing::subscriber::set_global_default(subscriber).expect("can't set_global_default"); +} + +#[tokio::main] +async fn main() -> ExitCode { + let args = Args::parse(); + + #[cfg(debug_assertions)] + fn check_isatty() {} + + #[cfg(not(debug_assertions))] + fn check_isatty() { + fn isatty(stream: libc::c_int) -> bool { + unsafe { libc::isatty(stream) != 0 } + } + + if isatty(libc::STDIN_FILENO) || isatty(libc::STDOUT_FILENO) { + die("cannot be run interactively; exiting") + } + } + + let command_words = shell_words::split(&args.command).unwrap_or_else(|_| { + die("parse error; exiting"); + }); + + // Check that SSH_AUTH_SOCK is provided and it is a socket + let auth_sock_path = env::var("SSH_AUTH_SOCK").unwrap_or_else(|_| { + die("SSH_AUTH_SOCK is not set"); + }); + + let sock_metadata = fs::metadata(&auth_sock_path) + .unwrap_or_else(|_| die("path in SSH_AUTH_SOCK does not exist")); + + if !sock_metadata.file_type().is_socket() { + die("path in SSH_AUTH_SOCK is not a socket") + } + + // Convert vector of String to vector of str + let command_words: Vec<_> = command_words.iter().map(String::as_str).collect(); + + let (command, args) = match command_words.as_slice() { + ["git-upload-pack", rest @ ..] | ["git", "upload-pack", rest @ ..] => { + (RequestedCommand::GitUploadPack, rest) + } + ["git-upload-archive", rest @ ..] | ["git", "upload-archive", rest @ ..] => { + (RequestedCommand::GitUploadArchive, rest) + } + ["git-receive-pack", rest @ ..] | ["git", "receive-pack", rest @ ..] => { + (RequestedCommand::GitReceivePack, rest) + } + _ => die("unknown command"), + }; + + // For now ignore all the extra options those commands can take + if args.len() != 1 { + die("invalid arguments supplied for git command") + } + + setup_tracing(); + + let query = args.first().unwrap(); + + match handle_command(command, Path::new(&auth_sock_path), query).await { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("josh-ssh-shell: error: {}", e); + ExitCode::FAILURE + } + } +} diff --git a/josh-ssh-shell/src/lib.rs b/josh-ssh-shell/src/lib.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/josh-ssh-shell/src/lib.rs @@ -0,0 +1 @@ +