Skip to content
Clone
fadf6a5
171 lines 5.8 KB Rust
use bytes::Bytes;
use russh::ChannelId;
use russh::server::Session;
use gitauth::ResolvedRepo;
use repo::actor::{ActorContext, RepoAccess};
use crate::error::SshError;
use crate::git_service::{GitService, parse_git_command, parse_repo_path, sanitize_for_log};
use crate::handler::GitSshHandler;
use crate::handler::git_op::GitOperation;
impl GitSshHandler {
pub async fn exec_request_impl(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), russh::Error> {
let mut service_label: &'static str = "unknown";
let result: Result<(), russh::Error> = async {
let cmd = std::str::from_utf8(data)
.map_err(|_| invalid_command_error("Invalid command encoding", session, channel))?;
let cmd = cmd.trim();
tracing::info!(
"ssh exec from {:?}: {}",
self.client_addr,
sanitize_for_log(cmd)
);
let Some((service, raw_path)) = parse_git_command(cmd) else {
return Err(invalid_command_error(
"Invalid git command",
session,
channel,
));
};
let Some((space, repo)) = parse_repo_path(raw_path) else {
return Err(invalid_command_error(
"Invalid repository path",
session,
channel,
));
};
service_label = service.as_subcommand();
let actor = match &self.operator {
Some(u) => ActorContext::for_user(u.uid),
None => ActorContext::anonymous(),
};
let required = if service.is_write() {
RepoAccess::Write
} else {
RepoAccess::Read
};
let resolved: ResolvedRepo = match self
.service
.auth
.authorize(space, repo, &actor, required)
.await
{
Ok(r) => r,
Err(e) => {
let msg = auth_error_message(&e);
tracing::warn!("ssh {service:?} {space}/{repo} denied: {msg}");
return Err(invalid_command_error(msg.as_str(), session, channel));
}
};
let bare = self.service.bare_path(&resolved);
if !bare.is_dir() {
let msg = format!("fatal: repository '{space}/{repo}' does not exist");
return Err(invalid_command_error(msg.as_str(), session, channel));
}
let adv = run_advertise(&bare, service).await.unwrap_or_default();
if !adv.is_empty() {
let _ = session.data(channel, Bytes::from(adv));
}
let _ = session.channel_success(channel);
let _ = session.flush();
let op = match service {
GitService::UploadPack => GitOperation::UploadPack { bare: bare.clone() },
_ => GitOperation::ReceivePack { bare: bare.clone() },
};
let state = self.channels.entry(channel).or_default();
state.git_op = Some(op);
state.repo_id = Some(resolved.repo.id);
state.channel_id = Some(resolved.channel.id);
state.actor_id = actor.user_id;
Ok(())
}
.await;
crate::metrics::record_exec(service_label, result.is_ok());
result
}
}
fn invalid_command_error(msg: &str, session: &mut Session, channel: ChannelId) -> russh::Error {
let _ = session.channel_failure(channel);
let _ = session.exit_status_request(channel, 1);
let _ = session.close(channel);
let _ = session.flush();
russh::Error::IO(std::io::Error::new(
std::io::ErrorKind::InvalidData,
msg.to_string(),
))
}
fn auth_error_message(e: &gitauth::GitAuthError) -> String {
match e {
gitauth::GitAuthError::BadRequest(m)
| gitauth::GitAuthError::Unauthorized(m)
| gitauth::GitAuthError::Forbidden(m)
| gitauth::GitAuthError::NotFound(m)
| gitauth::GitAuthError::Conflict(m) => m.clone(),
_ => "authentication or authorization failed".to_string(),
}
}
async fn run_advertise(
bare: &std::path::PathBuf,
service: GitService,
) -> Result<Vec<u8>, SshError> {
let mut cmd = tokio::process::Command::new("git");
cmd.current_dir(bare)
.arg(service.as_subcommand())
.arg("--advertise-refs")
.env("GIT_DIR", bare)
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(SshError::Io)?;
let stderr_task = if let Some(stderr) = child.stderr.take() {
Some(tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut s = stderr;
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf).await;
buf
}))
} else {
None
};
let mut stdout = child
.stdout
.take()
.ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
let mut out = Vec::new();
use tokio::io::AsyncReadExt;
stdout.read_to_end(&mut out).await.map_err(SshError::Io)?;
let status = child.wait().await.map_err(SshError::Io)?;
let stderr_bytes = match stderr_task {
Some(t) => t.await.unwrap_or_default(),
None => Vec::new(),
};
if !status.success() {
return Err(SshError::Internal(format!(
"git {} --advertise-refs failed: {}",
service.as_subcommand(),
String::from_utf8_lossy(&stderr_bytes)
)));
}
Ok(out)
}
// Suppress unused import warnings while keeping the door open for future expansion.
#[allow(dead_code)]
fn _keep_module_in_scope() {}