Skip to content
Clone
76a6ea8
248 lines 7.8 KB Rust
use bytes::Bytes;
use russh::ChannelId;
use russh::server::Session;
use uuid::Uuid;
use crate::share::access::authorize_repo_access;
use crate::share::errors::GitError;
use crate::smart::error::SmartError;
use crate::ssh::error::SshError;
use crate::ssh::git_service::{
GitService, build_git_advertise_command, parse_git_command, parse_repo_path, sanitize_for_log,
};
use crate::ssh::handler::GitSshHandler;
use crate::ssh::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 cmd = match std::str::from_utf8(data) {
Ok(c) => c,
Err(_) => {
invalid_command("invalid command encoding", session, channel).await;
return Ok(());
}
};
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 {
invalid_command("invalid git command", session, channel).await;
return Ok(());
};
let Some((space_name, repo_name)) = parse_repo_path(raw_path) else {
invalid_command("invalid repository path", session, channel).await;
return Ok(());
};
let actor_id = self.operator.as_ref().map(|u| u.uid);
let required = if service.is_write() {
"write"
} else {
"read"
};
if let Err(msg) = self
.authorize_repo(space_name, repo_name, service.is_write())
.await
{
tracing::warn!("ssh {service:?} {space_name}/{repo_name} denied: {msg}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
let ctx = match self
.service
.smart
.resolve(space_name, repo_name)
.await
{
Ok(c) => c,
Err(e) => {
let msg = format!("fatal: {e}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
};
let space_id = match self.space_id_for(&ctx.model.dir_id).await {
Ok(id) => id,
Err(_) => ctx.model.dir_id,
};
let bare = ctx.repo_dir();
if !bare.is_dir() {
let msg = format!(
"fatal: repository '{space_name}/{repo_name}' does not exist"
);
invalid_command(&msg, session, channel).await;
return Ok(());
}
let adv = match run_advertise(
&bare,
service,
self.service
.smart
.git
.state
.config
.git
.git_rpc_timeout_secs,
)
.await
{
Ok(adv) => adv,
Err(e) => {
let msg = format!("fatal: unable to read repository advertisement: {e}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
};
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() },
GitService::UploadArchive => GitOperation::UploadArchive { bare: bare.clone() },
GitService::ReceivePack => GitOperation::ReceivePack { bare: bare.clone() },
};
let state = self.channels.entry(channel).or_default();
state.git_op = Some(op);
state.repo_id = Some(ctx.model.id);
state.space_id = Some(space_id);
state.actor_id = actor_id;
let _ = required;
Ok(())
}
async fn authorize_repo(
&self,
space: &str,
repo: &str,
want_write: bool,
) -> Result<(), String> {
let svc = &self.service.smart.git;
let ctx = svc
.context(space.to_string(), repo.to_string())
.await
.map_err(|e| format!("{e}"))?;
let Some(operator) = &self.operator else {
return Err("authentication required".into());
};
authorize_repo_access(svc, operator.uid, &ctx, want_write, false)
.await
.map_err(|e| match e {
GitError::Forbidden => "permission denied".to_string(),
other => format!("{other}"),
})
}
async fn space_id_for(&self, dir_id: &Uuid) -> Result<Uuid, SmartError> {
crate::share::access::space_for_dir(self.service.smart.git.db_read(), *dir_id)
.await
.map_err(SmartError::from)?
.map(|s| s.id)
.ok_or_else(|| SmartError::NotFound("space".into()))
}
pub async fn shell_request_impl(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), russh::Error> {
let _ = session.data(channel, Bytes::from_static(
b"Welcome to GitData SSH. This is a git-only server; shell access is disabled.\r\n",
));
let _ = session.exit_status_request(channel, 0);
let _ = session.eof(channel);
let _ = session.close(channel);
let _ = session.flush();
Ok(())
}
}
async fn invalid_command(msg: &str, session: &mut Session, channel: ChannelId) {
let _ = session.data(channel, Bytes::from(format!("{msg}\n").into_bytes()));
let _ = session.channel_failure(channel);
let _ = session.exit_status_request(channel, 1);
let _ = session.eof(channel);
let _ = session.close(channel);
let _ = session.flush();
}
async fn run_advertise(
bare: &std::path::Path,
service: GitService,
timeout_secs: u64,
) -> Result<Vec<u8>, SshError> {
let mut cmd = build_git_advertise_command(service, bare);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(SshError::Io)?;
let stderr_task = match child.stderr.take() {
Some(stderr) => Some(tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let s = stderr;
let mut buf = Vec::new();
let _ = s.take((64 * 1024 * 1024) as u64 + 1)
.read_to_end(&mut buf)
.await;
buf
})),
None => None,
};
let stdout = child
.stdout
.take()
.ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
let mut out = Vec::new();
use tokio::io::AsyncReadExt;
stdout
.take((64 * 1024 * 1024) as u64 + 1)
.read_to_end(&mut out)
.await
.map_err(SshError::Io)?;
let status =
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), child.wait())
.await
{
Ok(Ok(s)) => s,
_ => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(SshError::Internal(format!(
"git {} --advertise-refs timed out",
service.as_subcommand()
)));
}
};
let stderr_bytes = match stderr_task {
Some(t) => t.await.map_err(|e| {
SshError::Internal(format!("stderr task join failed: {e}"))
})?,
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)
}
#[allow(dead_code)]
fn _keep_typed(_: GitError) {}