Skip to main content

max / mnw-cli

Add git proxy: bidirectional SSH-to-subprocess streaming New git module parses exec commands, authorizes via MNW internal API, and spawns git-upload-pack/git-receive-pack with piped I/O through the SSH channel. Handles stdin forwarding, EOF, and exit codes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 22:04 UTC
Commit: 1ae81513dc717a26929815a9d4e03317f828c2ee
Parent: 8826667
6 files changed, +369 insertions, -12 deletions
M src/api.rs +42
@@ -215,6 +215,12 @@ pub struct SshKeyInfo {
215 215 pub created_at: String,
216 216 }
217 217
218 + /// Response from the git authorize endpoint.
219 + #[derive(Debug, Deserialize)]
220 + pub struct GitAuthResponse {
221 + pub repo_path: String,
222 + }
223 +
218 224 /// Client for calling MNW internal API endpoints.
219 225 #[derive(Clone)]
220 226 pub struct MnwApiClient {
@@ -937,6 +943,42 @@ impl MnwApiClient {
937 943
938 944 // ── SSH keys ──
939 945
946 + /// Authorize a git operation and get the on-disk repo path.
947 + pub async fn git_authorize(
948 + &self,
949 + user_id: &str,
950 + operation: &str,
951 + owner: &str,
952 + repo_name: &str,
953 + ) -> anyhow::Result<GitAuthResponse> {
954 + let url = format!("{}/api/internal/git/authorize", self.base_url);
955 + let resp = self
956 + .http
957 + .post(&url)
958 + .bearer_auth(&self.service_token)
959 + .json(&serde_json::json!({
960 + "user_id": user_id,
961 + "operation": operation,
962 + "owner": owner,
963 + "repo_name": repo_name,
964 + }))
965 + .send()
966 + .await?;
967 +
968 + if !resp.status().is_success() {
969 + let status = resp.status();
970 + let body = resp.text().await.unwrap_or_default();
971 + // Parse JSON error if available, fall back to status text
972 + let msg = serde_json::from_str::<serde_json::Value>(&body)
973 + .ok()
974 + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
975 + .unwrap_or_else(|| format!("HTTP {status}"));
976 + anyhow::bail!("{msg}");
977 + }
978 +
979 + Ok(resp.json().await?)
980 + }
981 +
940 982 /// List registered SSH keys for a user.
941 983 pub async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> {
942 984 let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
@@ -14,6 +14,8 @@ pub struct Config {
14 14 pub host_key_path: PathBuf,
15 15 /// Base directory for staging uploaded files.
16 16 pub staging_dir: PathBuf,
17 + /// System user that owns git repositories (for `sudo -u`).
18 + pub git_user: String,
17 19 }
18 20
19 21 impl Config {
@@ -38,12 +40,16 @@ impl Config {
38 40 .unwrap_or_else(|_| "/var/lib/mnw-cli/staging".to_string())
39 41 .into();
40 42
43 + let git_user = std::env::var("GIT_SUDO_USER")
44 + .unwrap_or_else(|_| "git".to_string());
45 +
41 46 Ok(Config {
42 47 port,
43 48 api_url,
44 49 service_token,
45 50 host_key_path,
46 51 staging_dir,
52 + git_user,
47 53 })
48 54 }
49 55 }
M src/main.rs +1 -1
@@ -65,7 +65,7 @@ async fn main() -> anyhow::Result<()> {
65 65
66 66 let staging_dir = Arc::new(config.staging_dir);
67 67 let api_client = api::MnwApiClient::new(config.api_url, config.service_token);
68 - let mut server = ssh::MnwServer::new(api_client, staging_dir);
68 + let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user);
69 69
70 70 let addr = format!("0.0.0.0:{}", config.port);
71 71 tracing::info!(%addr, "listening for SSH connections");
@@ -0,0 +1,218 @@
1 + //! Git operation proxy: command parsing and subprocess management.
2 + //!
3 + //! When a git client connects via SSH (e.g., `git push git@ssh.makenot.work:max/repo.git`),
4 + //! the exec_request receives a command like `git-receive-pack 'max/repo.git'`. This module
5 + //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped
6 + //! through the SSH channel.
7 +
8 + use russh::server::Handle;
9 + use russh::ChannelId;
10 + use tokio::io::AsyncReadExt;
11 + use tokio::process::{Child, Command};
12 +
13 + /// Parse a git exec command into (operation, raw_path).
14 + ///
15 + /// Git clients send commands like:
16 + /// `git-upload-pack '/max/repo.git'`
17 + /// `git-receive-pack 'max/repo.git'`
18 + ///
19 + /// Returns `None` for non-git commands.
20 + pub fn parse_git_command(cmd: &str) -> Option<(&str, &str)> {
21 + let (operation, rest) = cmd.split_once(' ')?;
22 +
23 + match operation {
24 + "git-upload-pack" | "git-receive-pack" | "git-upload-archive" => {}
25 + _ => return None,
26 + }
27 +
28 + // Strip surrounding quotes (single or double)
29 + let path = rest.trim();
30 + let path = path
31 + .strip_prefix('\'')
32 + .and_then(|s| s.strip_suffix('\''))
33 + .or_else(|| path.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
34 + .unwrap_or(path);
35 +
36 + Some((operation, path))
37 + }
38 +
39 + /// Parse a repo path like "max/repo.git" or "/max/repo" into (owner, repo_name).
40 + ///
41 + /// Strips leading `/` and trailing `.git`.
42 + pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
43 + let path = path.strip_prefix('/').unwrap_or(path);
44 + let (owner, repo_name) = path.split_once('/')?;
45 +
46 + if owner.is_empty() || owner.contains("..") {
47 + return None;
48 + }
49 +
50 + // Strip trailing .git
51 + let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name);
52 +
53 + if repo_name.is_empty() || repo_name.contains("..") || repo_name.contains('/') {
54 + return None;
55 + }
56 +
57 + Some((owner, repo_name))
58 + }
59 +
60 + /// Spawn a git subprocess and wire its I/O through the SSH channel.
61 + ///
62 + /// Returns the child's stdin handle so the caller can forward SSH data() to it.
63 + /// Stdout/stderr forwarding and process cleanup run in background tasks.
64 + pub async fn spawn_git_process(
65 + git_user: &str,
66 + operation: &str,
67 + repo_path: &str,
68 + channel: ChannelId,
69 + handle: Handle,
70 + ) -> anyhow::Result<tokio::process::ChildStdin> {
71 + let mut child: Child = Command::new("sudo")
72 + .args(["-u", git_user, operation, repo_path])
73 + .stdin(std::process::Stdio::piped())
74 + .stdout(std::process::Stdio::piped())
75 + .stderr(std::process::Stdio::piped())
76 + .kill_on_drop(true)
77 + .spawn()?;
78 +
79 + let stdin = child
80 + .stdin
81 + .take()
82 + .ok_or_else(|| anyhow::anyhow!("failed to capture child stdin"))?;
83 + let stdout = child
84 + .stdout
85 + .take()
86 + .ok_or_else(|| anyhow::anyhow!("failed to capture child stdout"))?;
87 + let stderr = child
88 + .stderr
89 + .take()
90 + .ok_or_else(|| anyhow::anyhow!("failed to capture child stderr"))?;
91 +
92 + // Forward stdout → SSH channel data
93 + let stdout_handle = handle.clone();
94 + let stdout_task = tokio::spawn(async move {
95 + let mut reader = stdout;
96 + let mut buf = [0u8; 32768];
97 + loop {
98 + match reader.read(&mut buf).await {
99 + Ok(0) => break,
100 + Ok(n) => {
101 + let data = bytes::Bytes::copy_from_slice(&buf[..n]);
102 + if stdout_handle.data(channel, data).await.is_err() {
103 + break;
104 + }
105 + }
106 + Err(_) => break,
107 + }
108 + }
109 + });
110 +
111 + // Forward stderr → SSH channel extended data (type 1 = stderr)
112 + let stderr_handle = handle.clone();
113 + let stderr_task = tokio::spawn(async move {
114 + let mut reader = stderr;
115 + let mut buf = [0u8; 8192];
116 + loop {
117 + match reader.read(&mut buf).await {
118 + Ok(0) => break,
119 + Ok(n) => {
120 + let data = bytes::Bytes::copy_from_slice(&buf[..n]);
121 + if stderr_handle.extended_data(channel, 1, data).await.is_err() {
122 + break;
123 + }
124 + }
125 + Err(_) => break,
126 + }
127 + }
128 + });
129 +
130 + // Wait for subprocess to complete, then close the SSH channel
131 + tokio::spawn(async move {
132 + let _ = stdout_task.await;
133 + let _ = stderr_task.await;
134 +
135 + let exit_code = match child.wait().await {
136 + Ok(status) => status.code().unwrap_or(1) as u32,
137 + Err(_) => 1,
138 + };
139 +
140 + let _ = handle.exit_status_request(channel, exit_code).await;
141 + let _ = handle.eof(channel).await;
142 + let _ = handle.close(channel).await;
143 + });
144 +
145 + Ok(stdin)
146 + }
147 +
148 + #[cfg(test)]
149 + mod tests {
150 + use super::*;
151 +
152 + #[test]
153 + fn parse_git_upload_pack() {
154 + let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap();
155 + assert_eq!(op, "git-upload-pack");
156 + assert_eq!(path, "/max/repo.git");
157 + }
158 +
159 + #[test]
160 + fn parse_git_receive_pack_no_quotes() {
161 + let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap();
162 + assert_eq!(op, "git-receive-pack");
163 + assert_eq!(path, "max/repo.git");
164 + }
165 +
166 + #[test]
167 + fn parse_git_upload_archive_double_quotes() {
168 + let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap();
169 + assert_eq!(op, "git-upload-archive");
170 + assert_eq!(path, "/max/repo.git");
171 + }
172 +
173 + #[test]
174 + fn parse_non_git_command() {
175 + assert!(parse_git_command("ls -la").is_none());
176 + assert!(parse_git_command("scp -t /tmp/file").is_none());
177 + }
178 +
179 + #[test]
180 + fn parse_repo_path_basic() {
181 + let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
182 + assert_eq!(owner, "max");
183 + assert_eq!(repo, "repo");
184 + }
185 +
186 + #[test]
187 + fn parse_repo_path_with_leading_slash() {
188 + let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap();
189 + assert_eq!(owner, "max");
190 + assert_eq!(repo, "myproject");
191 + }
192 +
193 + #[test]
194 + fn parse_repo_path_no_git_suffix() {
195 + let (owner, repo) = parse_repo_path("max/repo").unwrap();
196 + assert_eq!(owner, "max");
197 + assert_eq!(repo, "repo");
198 + }
199 +
200 + #[test]
201 + fn parse_repo_path_rejects_traversal() {
202 + assert!(parse_repo_path("../evil/repo.git").is_none());
203 + assert!(parse_repo_path("max/../../etc.git").is_none());
204 + }
205 +
206 + #[test]
207 + fn parse_repo_path_rejects_empty() {
208 + assert!(parse_repo_path("").is_none());
209 + assert!(parse_repo_path("/").is_none());
210 + assert!(parse_repo_path("max/").is_none());
211 + assert!(parse_repo_path("max/.git").is_none());
212 + }
213 +
214 + #[test]
215 + fn parse_repo_path_rejects_nested() {
216 + assert!(parse_repo_path("max/sub/repo.git").is_none());
217 + }
218 + }
@@ -11,6 +11,7 @@ use russh::{Channel, ChannelId};
11 11 use tokio::sync::Mutex;
12 12
13 13 use crate::api::{MnwApiClient, UserInfo};
14 + use crate::ssh::git;
14 15 use crate::ssh::sftp::SftpSession;
15 16 use crate::ssh::terminal::TerminalHandle;
16 17 use crate::staging;
@@ -21,6 +22,7 @@ pub struct MnwHandler {
21 22 api: MnwApiClient,
22 23 peer_addr: Option<SocketAddr>,
23 24 staging_dir: Arc<PathBuf>,
25 + git_user: Arc<str>,
24 26 /// Populated after successful auth.
25 27 user: Option<UserInfo>,
26 28 /// Terminal dimensions (cols, rows).
@@ -29,18 +31,27 @@ pub struct MnwHandler {
29 31 app: Option<tui::AppHandle>,
30 32 /// Channels stored between open and shell/subsystem request.
31 33 channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
34 + /// Active git subprocess stdin handles, keyed by channel.
35 + git_processes: HashMap<ChannelId, tokio::process::ChildStdin>,
32 36 }
33 37
34 38 impl MnwHandler {
35 - pub fn new(api: MnwApiClient, peer_addr: Option<SocketAddr>, staging_dir: Arc<PathBuf>) -> Self {
39 + pub fn new(
40 + api: MnwApiClient,
41 + peer_addr: Option<SocketAddr>,
42 + staging_dir: Arc<PathBuf>,
43 + git_user: Arc<str>,
44 + ) -> Self {
36 45 Self {
37 46 api,
38 47 peer_addr,
39 48 staging_dir,
49 + git_user,
40 50 user: None,
41 51 term_size: (80, 24),
42 52 app: None,
43 53 channels: Arc::new(Mutex::new(HashMap::new())),
54 + git_processes: HashMap::new(),
44 55 }
45 56 }
46 57 }
@@ -220,6 +231,66 @@ impl russh::server::Handler for MnwHandler {
220 231 let command_line = String::from_utf8_lossy(data);
221 232 let handle = session.handle();
222 233
234 + let Some(ref user) = self.user else {
235 + let _ = handle.close(channel).await;
236 + return Ok(());
237 + };
238 +
239 + // Git operations: bidirectional streaming via subprocess proxy
240 + if let Some((operation, raw_path)) = git::parse_git_command(&command_line) {
241 + if let Some((owner, repo_name)) = git::parse_repo_path(raw_path) {
242 + tracing::info!(
243 + user = %user.username,
244 + %operation,
245 + %owner,
246 + %repo_name,
247 + "git operation"
248 + );
249 +
250 + match self
251 + .api
252 + .git_authorize(&user.user_id, operation, owner, repo_name)
253 + .await
254 + {
255 + Ok(auth) => {
256 + match git::spawn_git_process(
257 + &self.git_user,
258 + operation,
259 + &auth.repo_path,
260 + channel,
261 + handle.clone(),
262 + )
263 + .await
264 + {
265 + Ok(stdin) => {
266 + self.git_processes.insert(channel, stdin);
267 + session.channel_success(channel)?;
268 + }
269 + Err(e) => {
270 + tracing::error!(error = ?e, "failed to spawn git process");
271 + let msg = bytes::Bytes::from(format!(
272 + "fatal: internal error\r\n"
273 + ));
274 + let _ = handle.data(channel, msg).await;
275 + let _ = handle.exit_status_request(channel, 1).await;
276 + let _ = handle.eof(channel).await;
277 + let _ = handle.close(channel).await;
278 + }
279 + }
280 + }
281 + Err(e) => {
282 + let msg =
283 + bytes::Bytes::from(format!("fatal: {e}\r\n"));
284 + let _ = handle.extended_data(channel, 1, msg).await;
285 + let _ = handle.exit_status_request(channel, 1).await;
286 + let _ = handle.eof(channel).await;
287 + let _ = handle.close(channel).await;
288 + }
289 + }
290 + return Ok(());
291 + }
292 + }
293 +
223 294 // Check if this looks like a legacy SCP transfer
224 295 if command_line.starts_with("scp ") {
225 296 let msg: &[u8] =
@@ -233,11 +304,6 @@ impl russh::server::Handler for MnwHandler {
233 304 }
234 305
235 306 // Execute the command
236 - let Some(ref user) = self.user else {
237 - let _ = handle.close(channel).await;
238 - return Ok(());
239 - };
240 -
241 307 let user = user.clone();
242 308 let api = self.api.clone();
243 309 let cmd = command_line.to_string();
@@ -255,16 +321,30 @@ impl russh::server::Handler for MnwHandler {
255 321
256 322 async fn data(
257 323 &mut self,
258 - _channel: ChannelId,
324 + channel: ChannelId,
259 325 data: &[u8],
260 326 _session: &mut Session,
261 327 ) -> Result<(), Self::Error> {
262 - if let Some(ref app) = self.app {
328 + if let Some(stdin) = self.git_processes.get_mut(&channel) {
329 + use tokio::io::AsyncWriteExt;
330 + let _ = stdin.write_all(data).await;
331 + } else if let Some(ref app) = self.app {
263 332 app.send_input(data).await;
264 333 }
265 334 Ok(())
266 335 }
267 336
337 + async fn channel_eof(
338 + &mut self,
339 + channel: ChannelId,
340 + _session: &mut Session,
341 + ) -> Result<(), Self::Error> {
342 + if let Some(stdin) = self.git_processes.remove(&channel) {
343 + drop(stdin); // Closes pipe → subprocess sees EOF
344 + }
345 + Ok(())
346 + }
347 +
268 348 async fn window_change_request(
269 349 &mut self,
270 350 _channel: ChannelId,
M src/ssh/mod.rs +14 -3
@@ -1,5 +1,6 @@
1 1 //! SSH server implementation using russh.
2 2
3 + pub mod git;
3 4 pub mod handler;
4 5 pub mod sftp;
5 6 pub mod terminal;
@@ -14,11 +15,16 @@ use crate::api::MnwApiClient;
14 15 pub struct MnwServer {
15 16 api: MnwApiClient,
16 17 staging_dir: Arc<PathBuf>,
18 + git_user: Arc<str>,
17 19 }
18 20
19 21 impl MnwServer {
20 - pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>) -> Self {
21 - Self { api, staging_dir }
22 + pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>, git_user: String) -> Self {
23 + Self {
24 + api,
25 + staging_dir,
26 + git_user: Arc::from(git_user),
27 + }
22 28 }
23 29 }
24 30
@@ -27,6 +33,11 @@ impl russh::server::Server for MnwServer {
27 33
28 34 fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
29 35 tracing::info!(?peer_addr, "new SSH connection");
30 - handler::MnwHandler::new(self.api.clone(), peer_addr, Arc::clone(&self.staging_dir))
36 + handler::MnwHandler::new(
37 + self.api.clone(),
38 + peer_addr,
39 + Arc::clone(&self.staging_dir),
40 + Arc::clone(&self.git_user),
41 + )
31 42 }
32 43 }