source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "fs-err"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "getrandom"
version = "0.4.2"
"clap",
"clap_complete",
"color-eyre",
+ "fs-err",
"predicates",
+ "shell-words",
"tempfile",
"thiserror",
"tokio",
"lazy_static",
]
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
clap = { version = "*", features = ["derive", "env"] }
clap_complete = "*"
color-eyre = "*"
+fs-err = "*"
+shell-words = "*"
thiserror = "*"
tokio = { version = "*", features = ["full"] }
tracing = "*"
## Files
- `sshd_config` — drop into `/etc/ssh/sshd_config.d/` on the host
-- `quire-dispatch` — copy to `/usr/local/bin/quire-dispatch` and `chmod +x`
## Setup
sudo chmod 700 /home/git/.ssh
sudo chmod 600 /home/git/.ssh/authorized_keys
-3. Install the dispatch script:
-
- sudo cp quire-dispatch /usr/local/bin/quire-dispatch
- sudo chmod +x /usr/local/bin/quire-dispatch
-
-4. Install the sshd config:
+3. Install the sshd config:
sudo cp sshd_config /etc/ssh/sshd_config.d/quire.conf
sudo systemctl reload sshd
-5. Start the quire container:
+4. Start the quire container:
docker run -d --name quire-container quire
-6. Test:
+5. Test:
git clone git@localhost:foo.git /tmp/test-clone
## Notes
-The dispatch script is the security boundary between the host and the container.
-It only allows `git-receive-pack`, `git-upload-pack`, and `git-upload-archive`.
-Repo paths are validated: no `..` traversal, must end in `.git`, no double slashes.
-
-When `quire exec` is built (step 2), the ForceCommand will change to:
-
- ForceCommand docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"
-
-and this dispatch script will be replaced by the quire binary's own allowlist.
+SSH dispatch is handled by `quire exec` inside the container. The sshd
+ForceCommand passes `$SSH_ORIGINAL_COMMAND` directly to the binary,
+which validates the git command against an allowlist (git-receive-pack,
+git-upload-pack, git-upload-archive) and sanitizes the repository path
+before exec'ing the git subprocess.
+++ /dev/null
-#!/usr/bin/env bash
-# /usr/local/bin/quire-dispatch
-#
-# Invoked by sshd ForceCommand for the git user.
-# Parses $SSH_ORIGINAL_COMMAND to extract the repo path and git command,
-# then dispatches into the quire container via docker exec.
-#
-# $SSH_ORIGINAL_COMMAND examples:
-# git-receive-pack '/foo.git'
-# git-upload-pack '/foo.git'
-# git-upload-archive '/foo.git'
-
-set -euo pipefail
-
-# shellcheck disable=SC2154
-cmd="${SSH_ORIGINAL_COMMAND:-}"
-
-if [[ -z "$cmd" ]]; then
- echo "quire: interactive sessions are not supported" >&2
- exit 1
-fi
-
-# Extract the git subcommand (git-receive-pack, git-upload-pack, etc).
-git_cmd="${cmd%% *}"
-
-# Extract the repo path argument. Git sends it as a quoted string,
-# e.g. git-receive-pack '/foo.git'. Strip the leading command and
-# surrounding quotes.
-repo="${cmd#* }"
-repo="${repo#\'}"
-repo="${repo%\'}"
-repo="${repo#/}"
-
-case "$git_cmd" in
- git-receive-pack|git-upload-pack|git-upload-archive)
- ;;
- *)
- echo "quire: unsupported command: $git_cmd" >&2
- exit 1
- ;;
-esac
-
-# Validate repo path: no .., must end in .git, no double slashes.
-if [[ "$repo" == *".."* ]] || [[ "$repo" != *.git ]] || [[ "$repo" == *//* ]]; then
- echo "quire: invalid repository: $repo" >&2
- exit 1
-fi
-
-exec docker exec -i quire-container \
- /bin/sh -c "cd /var/quire/repos/${repo} && ${git_cmd} ."
# /etc/ssh/sshd_config.d/quire.conf
#
# Drop this file into sshd_config.d (or append to sshd_config).
-# Forces all connections as the git user through the quire dispatch script.
+# Forces all connections as the git user through quire exec.
# No port forwarding, agent forwarding, X11, or PTY — these are git/quire
# connections, not interactive shells.
Match User git
AuthorizedKeysFile /home/git/.ssh/authorized_keys
- ForceCommand /usr/local/bin/quire-dispatch
+ ForceCommand docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"
AllowTcpForwarding no
AllowAgentForwarding no
X11Forwarding no
-pub async fn run(command: Vec<String>) -> color_eyre::Result<()> {
- tracing::info!(?command, "quire exec dispatching");
- // TODO: parse command, validate against allowlist, exec
- Ok(())
+use std::os::unix::process::CommandExt;
+use std::path::Path;
+use std::process::Command;
+
+use color_eyre::eyre::{self, Context};
+use color_eyre::Result;
+
+const GIT_COMMANDS: &[&str] = &["git-receive-pack", "git-upload-pack", "git-upload-archive"];
+
+const REPOS_DIR: &str = "/var/quire/repos";
+
+pub async fn run(command: Vec<String>) -> Result<()> {
+ let input = if command.len() == 1 {
+ // Single argument: the full SSH_ORIGINAL_COMMAND string.
+ // e.g. git-receive-pack '/foo.git'
+ command[0].clone()
+ } else {
+ // Already split into words (e.g. from CLI: quire exec git-receive-pack /foo.git).
+ command.join(" ")
+ };
+
+ let words = shell_words::split(&input).context("failed to parse command")?;
+
+ if words.is_empty() {
+ eyre::bail!("no command provided");
+ }
+
+ let git_cmd = &words[0];
+
+ if !GIT_COMMANDS.contains(&git_cmd.as_str()) {
+ eyre::bail!("unsupported command: {git_cmd}");
+ }
+
+ if words.len() != 2 {
+ eyre::bail!("expected usage: {git_cmd} '<repo>', got {} arguments", words.len() - 1);
+ }
+
+ let repo = validate_repo_path(&words[1])?;
+
+ let repo_dir = Path::new(REPOS_DIR).join(&repo);
+ if !repo_dir.is_dir() {
+ eyre::bail!("repository not found: {repo}");
+ }
+
+ tracing::info!(%git_cmd, %repo, "dispatching git command");
+
+ let repo_dir = Path::new(REPOS_DIR).join(&repo);
+ let err = Command::new(git_cmd).arg(".").current_dir(&repo_dir).exec();
+
+ Err(eyre::eyre!("exec failed: {err}"))
+}
+
+/// Validate a repo path argument from the SSH protocol.
+///
+/// Git sends paths like '/foo.git'. We strip the leading slash,
+/// reject path traversal (..), require a .git suffix, and reject
+/// empty or double-slash paths.
+fn validate_repo_path(raw: &str) -> Result<String> {
+ let path = raw.trim_start_matches('/');
+
+ if path.is_empty() {
+ eyre::bail!("empty repository path");
+ }
+
+ if path.contains("..") {
+ eyre::bail!("invalid repository path: {raw}");
+ }
+
+ if !path.ends_with(".git") {
+ eyre::bail!("invalid repository path (must end in .git): {raw}");
+ }
+
+ if path.contains("//") {
+ eyre::bail!("invalid repository path: {raw}");
+ }
+
+ Ok(path.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn valid_repo_paths() {
+ assert_eq!(validate_repo_path("/foo.git").unwrap(), "foo.git");
+ assert_eq!(validate_repo_path("foo.git").unwrap(), "foo.git");
+ assert_eq!(validate_repo_path("/work/foo.git").unwrap(), "work/foo.git");
+ }
+
+ #[test]
+ fn rejects_traversal() {
+ assert!(validate_repo_path("/../etc/passwd").is_err());
+ assert!(validate_repo_path("/foo/../../bar.git").is_err());
+ }
+
+ #[test]
+ fn rejects_no_git_suffix() {
+ assert!(validate_repo_path("/foo").is_err());
+ }
+
+ #[test]
+ fn rejects_empty() {
+ assert!(validate_repo_path("").is_err());
+ assert!(validate_repo_path("/").is_err());
+ }
+
+ #[test]
+ fn rejects_double_slash() {
+ assert!(validate_repo_path("/foo//bar.git").is_err());
+ }
}
/// Dispatch an SSH-originated command.
Exec {
- /// The original SSH command string to parse and execute.
+ /// The original SSH command string (e.g. git-receive-pack '/foo.git').
+ /// Pass as a single argument: quire exec "git-receive-pack '/foo.git'"
command: Vec<String>,
},
}