//! One SSH connection: who is connecting, and running git for them.
//!
//! The connection is authenticated by a public key that belongs to a registered
//! user. The name in the clone URL - conventionally `git` - is ignored, because
//! it says nothing about who is connecting; the key is the identity, and the
//! repository path names whose it is.
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use russh::keys::ssh_key;
use russh::server::{Auth, ChannelOpenHandle, Handle, Msg, Session};
use russh::{Channel, ChannelId, ChannelOpenFailure, Disconnect, Sig};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, ChildStdin};
use tokio::sync::{RwLock, Semaphore, watch};
use tracing::{Instrument, Span, debug, error, info, warn};
use crate::entities::{ssh_keys, users};
use crate::repositories::store::find_repository_by_path;
use crate::repositories::{GitService, RepositoryPath, may_read, parse_git_command};
use crate::state_read;
use crate::{AppState, FROGUE_PUBLIC_URL_VAR_KEY, SshThrottleConfig};
/// Buffer size for child communication. Needs to be small-ish because it stays on the child's stack
const BUFFER: usize = 32 * 1024;
/// Yeah, so this one (pun intended) is weiiiird. It has only one use but i was feeling weird just puting "1" as arg. 1 is the file descriptor of stderr.
const STDERR: u32 = 1;
/// A connection that has been authenticated by a key belonging to a user.
struct Authenticated {
user_id: i64,
name: String,
}
/// The git process this connection is running, if any.
struct Git {
/// The child's standard input, taken - and so closed - when the client
/// sends EOF, which is what tells `upload-pack` and `receive-pack` that
/// the request is complete.
///
/// Kept separate from the handle below rather than dropped with it: the
/// process is still running at EOF, with its answer still to write.
stdin: Option<ChildStdin>,
/// Flipped to tell the process to stop. The child itself is awaited by a
/// task of its own, so it outlives this handle, and dropping the sender is
/// what ends a connection that went away without saying so.
kill: watch::Sender<bool>,
}
/// Why a command was refused, which the client is told nothing beyond "failure"
/// about - the reasons are ours to log, not a stranger's to probe.
enum Refusal {
/// No such repository, or no such owner.
Missing,
/// The key's owner may not do this to that repository.
Forbidden,
/// The database could not answer, which is the client's problem least of
/// all.
Unavailable,
}
/// An SSH public key that was offered but not registered yet.
///
/// Remembered so the registration TUI can pre-fill the key field with proof
/// of possession: the client signed with it, so we know they hold the private half.
#[derive(Clone)]
struct OfferedKey {
/// The key itself, in the form russh understands.
public_key: ssh_key::PublicKey,
/// The fingerprint, for matching against the database.
fingerprint: String,
}
/// The per-connection handler.
pub struct SessionHandler {
state: Arc<RwLock<AppState>>,
peer: Option<SocketAddr>,
/// The connection's root span. The accept loop enters it for the whole
/// connection; this handle is how fields that only become known partway
/// through - the user, once a key is verified - are recorded on it.
span: Span,
/// Set once a key belonging to a user has been verified.
user: Option<Authenticated>,
/// Whether a session channel has been opened, so that only one ever is.
opened: bool,
/// `GIT_PROTOCOL`, the one environment variable a git client asks for.
protocol: Option<String>,
/// The running git process, once a command has been accepted.
git: Option<Git>,
/// Keys that were offered but not registered yet. Remembered so the
/// registration TUI can show them pre-filled with proof of possession.
offered_keys: Vec<OfferedKey>,
/// A key that was verified (the client signed it) but belongs to no user yet.
/// This is the registration candidate.
verified_key: Option<OfferedKey>,
/// Whether this is a guest session (no authenticated user yet).
guest: bool,
/// Input channel for the TUI, when running.
tui_input: Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>>,
/// Semaphore limiting concurrent guest connections.
guest_semaphore: Arc<Semaphore>,
/// SSH throttle configuration.
throttle_config: SshThrottleConfig,
/// Permit held by this guest session, if any.
guest_permit: Option<tokio::sync::OwnedSemaphorePermit>,
/// Notified when the TUI starts, to cancel the guest idle timeout.
tui_started: Arc<tokio::sync::Notify>,
}
impl SessionHandler {
pub fn new(
state: Arc<RwLock<AppState>>,
peer: Option<SocketAddr>,
guest_semaphore: Arc<Semaphore>,
throttle_config: SshThrottleConfig,
) -> Self {
Self {
state,
peer,
span: super::connection_span(peer),
user: None,
opened: false,
protocol: None,
git: None,
offered_keys: Vec::new(),
verified_key: None,
guest: false,
tui_input: None,
guest_semaphore,
throttle_config,
guest_permit: None,
tui_started: Arc::new(tokio::sync::Notify::new()),
}
}
/// The connection's root span, for the accept loop to enter around the
/// whole session.
pub fn connection_span(&self) -> Span {
self.span.clone()
}
/// The registered key matching `public_key`, with the user it belongs to.
///
/// A lookup that fails is logged and treated as "no such key": the
/// connection is refused either way, and a database hiccup should not end
/// the server.
async fn lookup_key(
&self,
public_key: &ssh_key::PublicKey,
) -> Option<(ssh_keys::Model, users::Model)> {
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
let db = { state_read(&self.state).await.db.clone() };
let key = match ssh_keys::Entity::find()
.filter(ssh_keys::Column::Fingerprint.eq(&fingerprint))
.one(&db)
.await
{
Ok(key) => key,
Err(error) => {
error!(%error, "looking up an SSH key failed");
return None;
}
};
let key = key?;
let owner = match users::Entity::find_by_id(key.user_id).one(&db).await {
Ok(owner) => owner,
Err(error) => {
error!(%error, "looking up the owner of an SSH key failed");
return None;
}
};
owner.map(|owner| (key, owner))
}
/// Notes that `key` was just used to connect.
///
/// Best effort: failing to record it costs only the timestamp, and must not
/// turn a working key into a refused one.
async fn record_use(&self, key: ssh_keys::Model) {
let now = chrono::Utc::now().fixed_offset();
let mut key: ssh_keys::ActiveModel = key.into();
key.last_used_at = Set(Some(now));
let db = { state_read(&self.state).await.db.clone() };
if let Err(error) = key.update(&db).await {
debug!(%error, "recording when an SSH key was last used failed");
}
}
}
impl russh::server::Handler for SessionHandler {
type Error = russh::Error;
/// Called before the signature is checked, to spare the work of verifying
/// one we would refuse anyway.
///
/// For unregistered keys, we accept the offer so the client signs, then
/// verify the signature in auth_publickey and remember it for registration.
#[tracing::instrument(parent = &self.span, skip_all)]
async fn auth_publickey_offered(
&mut self,
_user: &str,
public_key: &ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
// Remember this key for potential registration, regardless of whether
// it's already registered. The auth_publickey step will verify possession.
if !self
.offered_keys
.iter()
.any(|k| k.fingerprint == fingerprint)
{
self.offered_keys.push(OfferedKey {
public_key: public_key.clone(),
fingerprint: fingerprint.clone(),
});
debug!(%fingerprint, "remembered an offered SSH key");
}
// Always accept so the client signs - we verify possession later
Ok(Auth::Accept)
}
/// Reached only once the client has proven it holds the private half of the
/// key it offered.
#[tracing::instrument(parent = &self.span, skip_all)]
async fn auth_publickey(
&mut self,
_user: &str,
public_key: &ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
// The `user` argument is the name in the clone URL, which for a forge
// is conventionally `git` and carries no information about who is
// connecting. It is ignored rather than trusted.
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
match self.lookup_key(public_key).await {
Some((key, owner)) => {
// Key is registered - this is an authenticated user
self.record_use(key).await;
info!(
user = %owner.name,
user_id = owner.id,
peer = ?self.peer,
"authenticated an SSH connection by key"
);
self.span.record("user.name", owner.name.as_str());
self.user = Some(Authenticated {
user_id: owner.id,
name: owner.name,
});
Ok(Auth::Accept)
}
None => {
// Key is not registered - this is a guest with a verified key
// Try to acquire a guest permit
let permit = match self.guest_semaphore.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
info!(
fingerprint = %fingerprint,
peer = ?self.peer,
"rejecting guest: too many concurrent guest connections"
);
self.span.record("ssh.refusal", "too_many_guests");
return Ok(Auth::reject());
}
};
// Accept authentication but mark as guest
self.verified_key = Some(OfferedKey {
public_key: public_key.clone(),
fingerprint: fingerprint.clone(),
});
self.guest = true;
self.guest_permit = Some(permit);
info!(
fingerprint = %fingerprint,
peer = ?self.peer,
"accepted unregistered key as guest for registration"
);
Ok(Auth::Accept)
}
}
}
/// Reject "none" authentication to force public key verification.
///
/// SSH clients typically try "none" first to discover available methods.
/// By rejecting it, we force the client to try publickey authentication,
/// which allows us to verify the key signature (proof of possession) before
/// allowing guest access for registration.
#[tracing::instrument(parent = &self.span, skip_all)]
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
debug!("rejecting auth_none to force publickey authentication");
Ok(Auth::reject())
}
/// Called when authentication succeeds. For guest sessions, this spawns a
/// watchdog that disconnects the connection if the guest doesn't open the
/// TUI within the idle timeout.
async fn auth_succeeded(&mut self, session: &mut Session) -> Result<(), Self::Error> {
if !self.guest {
// Not a guest, nothing to do
return Ok(());
}
// Spawn a watchdog for the guest idle timeout
let handle = session.handle();
let tui_started = self.tui_started.clone();
let idle_timeout_secs = self.throttle_config.guest_idle_timeout_secs;
let peer = self.peer;
tokio::spawn(async move {
tokio::select! {
_ = tui_started.notified() => {
// TUI started, watchdog cancelled
debug!(?peer, "guest opened TUI, idle watchdog cancelled");
}
_ = tokio::time::sleep(std::time::Duration::from_secs(idle_timeout_secs)) => {
// Timeout expired, disconnect
warn!(
?peer,
idle_timeout_secs,
"guest idle timeout expired, disconnecting"
);
let _ = handle.disconnect(
Disconnect::ByApplication,
"idle timeout".to_string(),
String::new(),
).await;
}
}
});
Ok(())
}
/// Git uses a single session channel per command, so a second has nothing
/// to be for.
async fn channel_open_session(
&mut self,
_channel: Channel<Msg>,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.opened {
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
self.opened = true;
reply.accept().await;
Ok(())
}
/// Git uses this to say which protocol version it speaks. Anything else a
/// client asks for is ignored rather than handed to the child.
async fn env_request(
&mut self,
channel: ChannelId,
variable_name: &str,
variable_value: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
if variable_name == "GIT_PROTOCOL" {
debug!(peer = ?self.peer, "the client speaks {variable_value}");
self.protocol = Some(variable_value.to_string());
}
session.channel_success(channel)?;
Ok(())
}
/// The command git runs over the connection, and the whole of what we
/// honour: a service, a path, and nothing else.
///
/// Gives the command a span of its own, under the connection's: it is the
/// unit a clone or a push is actually measured in, and the child process
/// and its pumps are hung off it so the whole exchange is one trace.
#[tracing::instrument(
name = "git.command",
parent = &self.span,
skip_all,
fields(
git.service = tracing::field::Empty,
repository.owner = tracing::field::Empty,
repository.slug = tracing::field::Empty,
git.exit_status = tracing::field::Empty,
git.refusal = tracing::field::Empty,
)
)]
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
// One command per connection: git opens a channel, asks for one
// service, and closes it. A second exec has nothing to attach to.
if self.git.is_some() {
session.channel_failure(channel)?;
return Ok(());
}
let Some(authenticated) = self.user.as_ref() else {
// Unreachable through a conforming client, but the channel is a
// request surface of its own and should not assume otherwise.
warn!(peer = ?self.peer, "an unauthenticated connection tried to run a command");
session.channel_failure(channel)?;
return Ok(());
};
let user_id = authenticated.user_id;
let user_name = authenticated.name.clone();
let Ok(command) = std::str::from_utf8(data) else {
warn!(peer = ?self.peer, "an SSH command was not UTF-8; refusing it");
session.channel_failure(channel)?;
return Ok(());
};
let (service, path) = match parse_git_command(command) {
Ok(parsed) => parsed,
Err(error) => {
debug!(%command, ?error, peer = ?self.peer, "refusing an SSH command");
session.channel_failure(channel)?;
return Ok(());
}
};
let span = Span::current();
span.record("git.service", tracing::field::display(service));
span.record("repository.owner", path.owner.as_str());
span.record("repository.slug", path.slug.as_str());
// Authorisation, and the directory to run git in, both come from the
// database and the repository root.
let (db, repo_root, hooks_dir, public_url) = {
let state = state_read(&self.state).await;
(
state.db.clone(),
state.repo_root.clone(),
state.repo_root.join(".frogue-hooks"),
state.public_url.clone(),
)
};
let (repository, _owner) = match authorise(&db, service, &path, user_id).await {
Ok(result) => result,
Err(refusal) => {
match refusal {
Refusal::Missing => {
span.record("git.refusal", "missing");
debug!(peer = ?self.peer, %path, "no such repository")
}
Refusal::Forbidden => {
span.record("git.refusal", "forbidden");
info!(%path, user = %user_name, peer = ?self.peer, "refusing a command on a repository this key may not reach")
}
Refusal::Unavailable => {
span.record("git.refusal", "unavailable");
}
}
session.channel_failure(channel)?;
return Ok(());
}
};
let repo_path = path.bare_path(&repo_root);
// The row can outlive the directory if something removed it from under
// us. Refusing is better than handing git a path to complain about.
if !repo_path.is_dir() {
span.record("git.refusal", "not-on-disk");
debug!(path = %repo_path.display(), "the repository is not on disk");
session.channel_failure(channel)?;
return Ok(());
}
match spawn(
service,
&repo_path,
self.protocol.as_deref(),
user_id,
&user_name,
repository.id,
&hooks_dir,
&public_url,
)
.await
{
Ok(started) => {
// The client is told the command started before the pumps are
// set loose, so nothing it writes is dropped for want of a
// place to put it.
session.channel_success(channel)?;
let Started {
git,
stdout,
stderr,
child,
killed,
} = started;
let handle = session.handle();
// The pumps and the wait outlive this call, so they carry the
// command span with them: the trace stays open until git has
// finished answering, not merely until the command was
// accepted.
tokio::spawn(pump(handle.clone(), channel, stdout, false).instrument(span.clone()));
tokio::spawn(pump(handle.clone(), channel, stderr, true).instrument(span.clone()));
tokio::spawn(wait(handle, channel, child, killed).instrument(span.clone()));
self.git = Some(git);
Ok(())
}
Err(()) => {
session.channel_failure(channel)?;
Ok(())
}
}
}
/// Route incoming data to either git or the TUI, depending on what's active.
async fn data(
&mut self,
_channel: ChannelId,
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
// If TUI is active, send data to it
if let Some(tui_input) = &self.tui_input {
if let Err(error) = tui_input.send(data.to_vec()) {
debug!(%error, "sending data to TUI failed");
}
return Ok(());
}
// Otherwise, send to git if running
if let Some(stdin) = self.git.as_mut().and_then(|git| git.stdin.as_mut())
&& let Err(error) = stdin.write_all(data).await
{
debug!(%error, "writing to git's standard input failed");
}
Ok(())
}
/// Closing the child's standard input ends its request, which is what tells
/// `upload-pack` and `receive-pack` the client has said all it intends to.
///
/// The process is left alone: it still has an answer to write, and its exit
/// status is what the client waits for. Dropping the whole handle here would
/// drop the kill sender too, which the waiting task reads as "stop" - and
/// that would cut the answer off mid-flight.
async fn channel_eof(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(git) = self.git.as_mut() {
git.stdin = None;
}
Ok(())
}
async fn channel_close(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(git) = self.git.take() {
let _ = git.kill.send(true);
}
Ok(())
}
/// There is one process on this channel and nothing else to signal, so any
/// signal the client sends means the same thing to us: stop it.
async fn signal(
&mut self,
_channel: ChannelId,
signal: Sig,
_session: &mut Session,
) -> Result<(), Self::Error> {
debug!(?signal, peer = ?self.peer, "stopping git on the client's signal");
if let Some(git) = self.git.as_ref() {
let _ = git.kill.send(true);
}
Ok(())
}
/// Accept PTY requests from guests for the registration TUI.
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
if self.user.is_some() {
// Authenticated users don't need a PTY yet
session.channel_failure(channel)?;
} else {
// Guests get a PTY for the registration TUI
session.channel_success(channel)?;
}
Ok(())
}
/// Launch the registration TUI for guest users.
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
if self.user.is_some() {
// Authenticated users can't request a shell yet
debug!(peer = ?self.peer, "authenticated user requested shell; not yet supported");
session.channel_failure(channel)?;
return Ok(());
}
// Guest user - launch the registration TUI
info!(peer = ?self.peer, "launching registration TUI for guest");
// Notify the idle watchdog that the TUI is starting.
// TODO: check that it really cuts the connection if we're still on the login after SSH_TUI_TIMEOUT_SECONDS_VAR_KEY
self.tui_started.notify_one();
// Create channels for TUI communication
let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel();
let (output_tx, mut output_rx) = tokio::sync::mpsc::unbounded_channel();
// Spawn the TUI task with a timeout
let verified_key = self.verified_key.as_ref().map(|k| k.public_key.clone());
let state = self.state.clone();
let handle = session.handle();
let throttle_config = self.throttle_config.clone();
let peer = self.peer;
tokio::spawn(async move {
let tui_timeout = std::time::Duration::from_secs(throttle_config.tui_timeout_secs);
let result = tokio::time::timeout(
tui_timeout,
super::tui::run_registration(&mut input_rx, output_tx, verified_key, state),
)
.await;
match result {
Ok(Ok(Some(user))) => {
info!(user = %user.name, "user registered successfully via SSH TUI");
// Send success message
let msg = format!("\r\n✓ Registration successful! Welcome, {}.\r\n", user.name);
let _ = handle.data(channel, msg.into_bytes());
}
Ok(Ok(None)) => {
debug!("user cancelled registration");
let _ = handle.data(channel, b"\r\nRegistration cancelled.\r\n".to_vec());
}
Ok(Err(e)) => {
error!(%e, "registration TUI failed");
let _ = handle.data(
channel,
format!("\r\n✗ Registration failed: {}\r\n", e).into_bytes(),
);
}
Err(_) => {
// Timeout expired
warn!(
?peer,
tui_timeout_secs = throttle_config.tui_timeout_secs,
"TUI registration timeout expired"
);
let _ = handle.data(
channel,
b"\r\n[X] Registration timed out. Please reconnect.\r\n".to_vec(),
);
let _ = handle
.disconnect(
Disconnect::ByApplication,
"registration timeout".to_string(),
String::new(),
)
.await;
}
}
// Close the channel
let _ = handle.close(channel).await;
});
// Spawn task to forward output from TUI to SSH channel
let handle = session.handle();
tokio::spawn(async move {
while let Some(data) = output_rx.recv().await {
if handle.data(channel, data).await.is_err() {
break;
}
}
});
// Store the input sender so we can route data events to the TUI
self.tui_input = Some(input_tx);
session.channel_success(channel)?;
Ok(())
}
/// There is no subsystem, `sftp` or otherwise, either.
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
debug!(%name, peer = ?self.peer, "refusing a subsystem request");
session.channel_failure(channel)?;
Ok(())
}
}
/// Everything a git child needs once it has been started: the pipes to move
/// data along, and the handle to move it with.
struct Started {
git: Git,
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
child: Child,
killed: watch::Receiver<bool>,
}
/// Starts `git` for this connection, with its standard streams piped so they
/// can be moved across the SSH channel.
async fn spawn(
service: GitService,
repo_path: &Path,
protocol: Option<&str>,
user_id: i64,
user_name: &str,
repository_id: i64,
hooks_dir: &Path,
public_url: &str,
) -> Result<Started, ()> {
let mut command = tokio::process::Command::new("git");
command
// Configure git to use our hooks directory and enable proc-receive
.arg("-c")
.arg(format!("core.hooksPath={}", hooks_dir.display()))
.arg("-c")
.arg("receive.procReceiveRefs=refs/patches")
.arg("-c")
.arg("receive.procReceiveRefs=refs/patches/*")
.arg("-c")
.arg("receive.advertisePushOptions=true")
.arg(service.subcommand())
.arg(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If the connection drops, the child must not be left running: it is
// only ever reachable through this channel.
.kill_on_drop(true);
if let Some(protocol) = protocol {
command.env("GIT_PROTOCOL", protocol);
}
// Pass environment variables for the hooks
command
.env("FROGUE_PUSH_USER_ID", user_id.to_string())
.env("FROGUE_PUSH_USER_NAME", user_name)
.env("FROGUE_REPOSITORY_ID", repository_id.to_string())
.env(FROGUE_PUBLIC_URL_VAR_KEY, public_url);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
error!(%error, %service, path = %repo_path.display(), "starting git failed");
return Err(());
}
};
let (Some(stdin), Some(stdout), Some(stderr)) =
(child.stdin.take(), child.stdout.take(), child.stderr.take())
else {
error!(%service, "git started without the standard streams this needs");
let _ = child.kill().await;
return Err(());
};
let (kill, killed) = watch::channel(false);
debug!(%service, path = %repo_path.display(), user = %user_name, user_id, "started git");
Ok(Started {
git: Git {
stdin: Some(stdin),
kill,
},
stdout,
stderr,
child,
killed,
})
}
/// Checks that `user_id` may run `service` on the repository `path` names.
/// Returns the repository and owner if authorized.
async fn authorise(
db: &DatabaseConnection,
service: GitService,
path: &RepositoryPath,
user_id: i64,
) -> Result<
(
crate::entities::repositories::Model,
crate::entities::users::Model,
),
Refusal,
> {
let repository = find_repository_by_path(db, &path.owner, &path.slug)
.await
.map_err(|error| {
error!(%error, "looking up a repository failed");
Refusal::Unavailable
})?;
let Some((owner, repository)) = repository else {
return Err(Refusal::Missing);
};
let allowed = match service {
GitService::UploadPack => {
// A connection only ever gets this far by proving a key, so the
// viewer is always known here.
may_read(repository.visibility, repository.owner_id, Some(user_id))
}
GitService::ReceivePack => {
// Owner can always push. For public repos, any authenticated user can
// run receive-pack; the pre-receive hook will restrict what refs they
// may touch.
repository.owner_id == user_id
|| repository.visibility == crate::entities::repositories::Visibility::Public
}
};
if allowed {
Ok((repository, owner))
} else {
Err(Refusal::Forbidden)
}
}
/// Sends what git writes on one of its standard streams to the client.
///
/// Standard output is the git protocol itself and goes back over the channel.
/// Standard error is git's complaints - a rejected non-fast-forward push, a
/// missing object - which git clients render to the terminal, so it goes over
/// extended data the way SSH carries standard error.
async fn pump(
handle: Handle,
channel: ChannelId,
mut stream: impl AsyncRead + Unpin,
stderr: bool,
) {
let mut buffer = vec![0u8; BUFFER];
loop {
match stream.read(&mut buffer).await {
Ok(0) => break,
Ok(read) => {
let sent = if stderr {
handle
.extended_data(channel, STDERR, buffer[..read].to_vec())
.await
} else {
handle.data(channel, buffer[..read].to_vec()).await
};
// The client is gone, so the channel is finished.
if sent.is_err() {
break;
}
}
Err(error) => {
debug!(%error, stderr, "reading git's output failed");
break;
}
}
}
}
/// Waits for git to finish and tells the client how it went.
///
/// An exit status of 0 is what git treats as success, so anything the child is
/// killed by - the client disconnecting, a signal it was sent - is reported as
/// 1, the convention for "something went wrong".
async fn wait(
handle: Handle,
channel: ChannelId,
mut child: Child,
mut killed: watch::Receiver<bool>,
) {
let status = tokio::select! {
_ = killed.changed() => {
let _ = child.kill().await;
child.wait().await.ok()
}
status = child.wait() => status.ok(),
};
let exit_status = match status {
Some(status) => status.code().unwrap_or(1) as u32,
None => 1,
};
// Entered in the command's span, so this lands on it: how the command
// ended is the last thing a trace needs before it can be read.
Span::current().record("git.exit_status", exit_status as u64);
let _ = handle.exit_status_request(channel, exit_status).await;
let _ = handle.eof(channel).await;
let _ = handle.close(channel).await;
}