]> git.nega.tv - josh/git-shell-multiplex/commitdiff
Initial Commit! main
authorJoshua Simmons <josh@nega.tv>
Fri, 30 May 2025 12:31:13 +0000 (14:31 +0200)
committerJoshua Simmons <josh@nega.tv>
Sat, 31 May 2025 13:24:48 +0000 (15:24 +0200)
.gitignore [new file with mode: 0644]
Cargo.lock [new file with mode: 0644]
Cargo.toml [new file with mode: 0644]
src/main.rs [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..ea8c4bf
--- /dev/null
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644 (file)
index 0000000..1ea0c30
--- /dev/null
@@ -0,0 +1,16 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "git-shell-multiplex"
+version = "0.1.0"
+dependencies = [
+ "shlex",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644 (file)
index 0000000..3704d5d
--- /dev/null
@@ -0,0 +1,7 @@
+[package]
+name = "git-shell-multiplex"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+shlex = "1.3.0"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644 (file)
index 0000000..db2d9cb
--- /dev/null
@@ -0,0 +1,198 @@
+//! git-shell-multiplex
+//!
+//! An implementation of `git-shell` which allows multiple users to access git
+//! repositories through a single linux user account. It works by using the
+//! `command` parameter of `authorized_keys` entries to associate a username
+//! with a public key, then correlating that with the permissions of the git
+//! repository.
+//!
+//! There's no support for an interactive shell, and only the following git
+//! commands are available.
+//! * `git-receive-pack`
+//! * `git-upload-pack`
+//! * `git-upload-archive`
+//!
+//! Control Files
+//! ===
+//!
+//! `PUBLIC_ACCESS_FILE`
+//! ---
+//! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default:
+//! `git-daemon-export-ok`) within the git repository, if found all users will be
+//! granted read-access to the repository.
+//!
+//! `CONTRIBUTORS_FILE`
+//! ---
+//! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default:
+//! `git-shell-multiplex-contributors`) within the git repository. The file is
+//! expected to contain a newline-separated list of additional contributor
+//! usernames. These users will be granted write access to the repository, in
+//! addition to the repository owner.
+//!
+//! Example `authorized_keys` file
+//! ===
+//!
+//! ```text
+//! command="/home/git/git-shell-multiplex josh",restrict <pubkey>
+//! command="/home/git/git-shell-multiplex sarah",restrict <pubkey>
+//! ```
+//!
+//! Accepts paths of the form `<user>/<repo>.git` only.
+
+use std::fs::File;
+use std::io::{BufRead as _, BufReader, Lines};
+use std::path::{Path, PathBuf};
+
+enum Error {
+    Usage,
+    NotAShell { username: String },
+    NoPermission,
+}
+
+/// Repositories should be found at `<GIT_DIR>/<user>/<repo>`. Note the lack of `.git`.
+const GIT_DIR: &str = "/var/git/";
+
+/// If a file with this name is present in a git repository, read-access will be
+/// granted to anybody with ssh access.
+const PUBLIC_ACCESS_FILE: &str = "git-daemon-export-ok";
+
+/// This file, if it exists, should contain a newline-separated list of additional
+/// contributor usernames. These users will be granted write access to the
+/// repository.
+///
+/// If the file does not exist, only the owner of the repository will be able to
+/// write to the repository.
+const CONTRIBUTORS_FILE: &str = "git-shell-multiplex-contributors";
+
+fn read_lines<P>(path: P) -> std::io::Result<Lines<BufReader<File>>>
+where
+    P: AsRef<Path>,
+{
+    let file = File::open(path)?;
+    Ok(BufReader::new(file).lines())
+}
+
+fn git_shell() -> Result<(), Error> {
+    let mut args = std::env::args();
+
+    // First arg is the process name.
+    let _ = args.next().ok_or(Error::Usage)?;
+    let username = args.next().ok_or(Error::Usage)?;
+
+    // Usage error if we pass extra arguments.
+    if args.next().is_some() {
+        return Err(Error::Usage);
+    }
+
+    // If sshd hasn't set the original command, some goober is trying to use the
+    // git account as a shell.
+    let Some(ssh_original_command) = std::env::var_os("SSH_ORIGINAL_COMMAND") else {
+        return Err(Error::NotAShell { username });
+    };
+
+    // If the incoming command isn't valid unicode.
+    let ssh_original_command = ssh_original_command.to_str().ok_or(Error::NoPermission)?;
+
+    // The `SSH_ORIGINAL_COMMAND` is escaped, so we need to undo that.
+    let command;
+    let path;
+    {
+        let mut words = shlex::Shlex::new(ssh_original_command);
+        command = words.next().ok_or(Error::NoPermission)?;
+        path = words.next().ok_or(Error::NoPermission)?;
+        if words.next().is_some() {
+            return Err(Error::NoPermission);
+        }
+    };
+
+    // We want to support both `git-blah-blah` and `git blah-blah`.
+    let command = if let Some(rest) = command.strip_prefix("git-") {
+        rest
+    } else if let Some(rest) = command.strip_prefix("git ") {
+        rest
+    } else {
+        return Err(Error::NoPermission);
+    };
+
+    // We exclusively support paths that look like `<user>/<repo>.git`.
+    let (repo_owner, repo_name) = path.split_once('/').ok_or(Error::NoPermission)?;
+    let repo_name = repo_name.strip_suffix(".git").ok_or(Error::NoPermission)?;
+
+    // Hopefully this is enough...
+    const UNSAFE_CHARS: &[char] = &['\\', '.', '/', ':', '?'];
+    if repo_owner.contains(UNSAFE_CHARS) || repo_name.contains(UNSAFE_CHARS) {
+        return Err(Error::NoPermission);
+    }
+
+    // On the filesystem we don't have a `.git` suffix for repositories.
+    let repo_path = PathBuf::from(format!("{GIT_DIR}/{repo_owner}/{repo_name}"));
+
+    // Is this actually a git repo?
+    let has_head_file = matches!(std::fs::exists(repo_path.join("HEAD")), Ok(true));
+    if !has_head_file {
+        return Err(Error::NoPermission);
+    }
+
+    // If the repo contains a file named `git-daemon-export-ok` we allow public access.
+    let is_repo_public = matches!(
+        std::fs::exists(repo_path.join(PUBLIC_ACCESS_FILE)),
+        Ok(true)
+    );
+
+    let is_user_a_contributor =
+        if let Ok(contributors) = read_lines(repo_path.join(CONTRIBUTORS_FILE)) {
+            contributors
+                .map_while(Result::ok)
+                .any(|contributor_username| contributor_username == username)
+        } else {
+            false
+        };
+
+    // Check the permissions...
+    let is_user_the_repo_owner = repo_owner == username;
+    let write_access = is_user_the_repo_owner || is_user_a_contributor;
+    let read_access = is_user_the_repo_owner || is_user_a_contributor || is_repo_public;
+
+    let has_permission = match command {
+        "receive-pack" => write_access,
+        "upload-pack" | "upload-archive" => read_access,
+
+        // Spooky unknown commands ~~~
+        _ => false,
+    };
+
+    if !has_permission {
+        return Err(Error::NoPermission);
+    }
+
+    // Finally we can actually run the git command.
+    let program = format!("git-{command}");
+    let mut child = std::process::Command::new(program)
+        .arg(repo_path)
+        .spawn()
+        .map_err(|_| Error::NoPermission)?;
+    let exit_status = child.wait().map_err(|_| Error::NoPermission)?;
+
+    if exit_status.success() {
+        Ok(())
+    } else {
+        Err(Error::NoPermission)
+    }
+}
+
+pub fn main() -> std::process::ExitCode {
+    match git_shell() {
+        Ok(_) => std::process::ExitCode::SUCCESS,
+        Err(error) => match error {
+            Error::Usage => {
+                eprintln!("usage: `git-shell-multiplex <username>");
+                std::process::ExitCode::FAILURE
+            }
+            Error::NotAShell { username } => {
+                eprintln!("Hello {username}! This isn't the shell you're looking for.");
+                std::process::ExitCode::SUCCESS
+            }
+            Error::NoPermission => std::process::ExitCode::FAILURE,
+        },
+    }
+}