615 lines · 22900 bytes
1 use std::process::Stdio;
2 use std::sync::Arc;
3
4 use async_trait::async_trait;
5 use russh::keys::PublicKeyBase64;
6 use russh::server::{self, Auth, Msg, Session};
7 use russh::{Channel, ChannelId, CryptoVec};
8 use sqlx::SqlitePool;
9 use tokio::io::{AsyncReadExt, AsyncWriteExt};
10 use tokio::process::Command;
11
12 use crate::auth;
13 use crate::config::Config;
14 use crate::repos;
15 use crate::validate;
16
17 enum SignupState {
18 WaitingForUsername,
19 WaitingForInvite { username: String },
20 }
21
22 pub struct SshServer {
23 cfg: Arc<Config>,
24 pool: SqlitePool,
25 }
26
27 impl SshServer {
28 pub fn new(cfg: Arc<Config>, pool: SqlitePool) -> Self {
29 Self { cfg, pool }
30 }
31 }
32
33 impl server::Server for SshServer {
34 type Handler = SshHandler;
35
36 fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> SshHandler {
37 SshHandler {
38 cfg: self.cfg.clone(),
39 pool: self.pool.clone(),
40 username: None,
41 user_id: None,
42 auth_key_openssh: None,
43 signup_state: None,
44 input_buf: String::new(),
45 child_stdin: None,
46 }
47 }
48 }
49
50 pub struct SshHandler {
51 cfg: Arc<Config>,
52 pool: SqlitePool,
53 username: Option<String>,
54 user_id: Option<i64>,
55 auth_key_openssh: Option<String>,
56 signup_state: Option<SignupState>,
57 input_buf: String,
58 child_stdin: Option<tokio::process::ChildStdin>,
59 }
60
61 impl SshHandler {
62 async fn handle_openhub_command(
63 &mut self,
64 cmd: &str,
65 channel_id: ChannelId,
66 session: &mut Session,
67 ) -> bool {
68 let parts: Vec<&str> = cmd.trim().split_whitespace().collect();
69 if parts.is_empty() || parts[0] != "repo" {
70 return false;
71 }
72
73 let username = match &self.username {
74 Some(u) => u.clone(),
75 None => return false,
76 };
77 let user_id = match self.user_id {
78 Some(id) => id,
79 None => return false,
80 };
81
82 match parts.get(1).copied() {
83 Some("new") => {
84 let name = match parts.get(2) {
85 Some(n) => *n,
86 None => {
87 session.data(
88 channel_id,
89 CryptoVec::from(b"Usage: repo new <name>\r\n".to_vec()),
90 );
91 session.exit_status_request(channel_id, 1);
92 session.eof(channel_id);
93 session.close(channel_id);
94 return true;
95 }
96 };
97 match repos::create_repo(
98 &self.pool,
99 &self.cfg.repos_root,
100 &self.cfg.hook_path,
101 self.cfg.max_repos_per_user,
102 user_id,
103 &username,
104 name,
105 )
106 .await
107 {
108 Ok(()) => {
109 let msg = format!("Created {}/{}\r\n", username, name);
110 session.data(channel_id, CryptoVec::from(msg.into_bytes()));
111 session.exit_status_request(channel_id, 0);
112 }
113 Err(e) => {
114 let msg = match e.as_str() {
115 "invalid_repo_name" => "error: invalid repository name\r\n",
116 "repo_quota_exceeded" => "error: repository quota exceeded\r\n",
117 "repo_exists" => "error: repository already exists\r\n",
118 _ => "error: failed to create repository\r\n",
119 };
120 session.data(channel_id, CryptoVec::from(msg.as_bytes().to_vec()));
121 session.exit_status_request(channel_id, 1);
122 }
123 }
124 session.eof(channel_id);
125 session.close(channel_id);
126 true
127 }
128 Some("list") => {
129 let target = parts.get(2).copied().unwrap_or(username.as_str());
130 let rows = sqlx::query!(
131 r#"SELECT repos.name as "name!: String", users.username as "owner!: String"
132 FROM repos JOIN users ON users.id = repos.owner_id
133 WHERE users.username = ?
134 ORDER BY repos.name"#,
135 target
136 )
137 .fetch_all(&self.pool)
138 .await;
139
140 match rows {
141 Ok(repos) => {
142 if repos.is_empty() {
143 session.data(
144 channel_id,
145 CryptoVec::from(b"No repositories found.\r\n".to_vec()),
146 );
147 } else {
148 for r in repos {
149 let line = format!("{}/{}\r\n", r.owner, r.name);
150 session.data(channel_id, CryptoVec::from(line.into_bytes()));
151 }
152 }
153 session.exit_status_request(channel_id, 0);
154 }
155 Err(_) => {
156 session.data(
157 channel_id,
158 CryptoVec::from(b"error: failed to list repos\r\n".to_vec()),
159 );
160 session.exit_status_request(channel_id, 1);
161 }
162 }
163 session.eof(channel_id);
164 session.close(channel_id);
165 true
166 }
167 Some("delete") => {
168 let name = match parts.get(2) {
169 Some(n) => *n,
170 None => {
171 session.data(
172 channel_id,
173 CryptoVec::from(b"Usage: repo delete <name>\r\n".to_vec()),
174 );
175 session.exit_status_request(channel_id, 1);
176 session.eof(channel_id);
177 session.close(channel_id);
178 return true;
179 }
180 };
181 match repos::delete_repo(&self.pool, &self.cfg.repos_root, &username, name).await {
182 Ok(()) => {
183 let msg = format!("Deleted {}/{}\r\n", username, name);
184 session.data(channel_id, CryptoVec::from(msg.into_bytes()));
185 session.exit_status_request(channel_id, 0);
186 }
187 Err(e) => {
188 let msg = match e.as_str() {
189 "not_found" => "error: repository not found\r\n",
190 _ => "error: failed to delete repository\r\n",
191 };
192 session.data(channel_id, CryptoVec::from(msg.as_bytes().to_vec()));
193 session.exit_status_request(channel_id, 1);
194 }
195 }
196 session.eof(channel_id);
197 session.close(channel_id);
198 true
199 }
200 _ => false,
201 }
202 }
203
204 async fn process_signup_input(&mut self, channel: ChannelId, session: &mut Session) {
205 let input = self.input_buf.trim().to_string();
206 self.input_buf.clear();
207
208 let state = self.signup_state.take();
209 match state {
210 Some(SignupState::WaitingForUsername) => {
211 if input.is_empty() {
212 session.data(
213 channel,
214 CryptoVec::from(b"Username cannot be empty.\r\nChoose a username: ".to_vec()),
215 );
216 self.signup_state = Some(SignupState::WaitingForUsername);
217 return;
218 }
219
220 if !validate::validate_username(&input) {
221 session.data(
222 channel,
223 CryptoVec::from(
224 b"Invalid username. Use 2-32 chars: lowercase letters, digits, hyphens, underscores.\r\nChoose a username: ".to_vec(),
225 ),
226 );
227 self.signup_state = Some(SignupState::WaitingForUsername);
228 return;
229 }
230
231 self.signup_state = Some(SignupState::WaitingForInvite {
232 username: input,
233 });
234 session.data(
235 channel,
236 CryptoVec::from(b"Enter invite code: ".to_vec()),
237 );
238 }
239 Some(SignupState::WaitingForInvite { username }) => {
240 if input.is_empty() {
241 session.data(
242 channel,
243 CryptoVec::from(
244 b"Invite code cannot be empty.\r\nEnter invite code: ".to_vec(),
245 ),
246 );
247 self.signup_state = Some(SignupState::WaitingForInvite { username });
248 return;
249 }
250
251 let public_key = match &self.auth_key_openssh {
252 Some(k) => k.clone(),
253 None => {
254 session.data(
255 channel,
256 CryptoVec::from(b"Internal error.\r\n".to_vec()),
257 );
258 session.exit_status_request(channel, 1);
259 session.eof(channel);
260 session.close(channel);
261 return;
262 }
263 };
264
265 match auth::create_user(&self.pool, &username, &public_key, &input).await {
266 Ok(user) => {
267 self.username = Some(user.username.clone());
268 let msg = format!(
269 "\r\nAccount created! Welcome, {}.\r\nYou can now clone and push repos over SSH.\r\n",
270 user.username
271 );
272 session.data(channel, CryptoVec::from(msg.into_bytes()));
273 session.exit_status_request(channel, 0);
274 session.eof(channel);
275 session.close(channel);
276 }
277 Err(e) => match e.as_str() {
278 "username_taken" => {
279 session.data(
280 channel,
281 CryptoVec::from(
282 b"That username is taken.\r\nChoose a username: ".to_vec(),
283 ),
284 );
285 self.signup_state = Some(SignupState::WaitingForUsername);
286 }
287 "invalid_invite" => {
288 session.data(
289 channel,
290 CryptoVec::from(
291 b"Invalid invite code.\r\nEnter invite code: ".to_vec(),
292 ),
293 );
294 self.signup_state = Some(SignupState::WaitingForInvite { username });
295 }
296 "invite_used" => {
297 session.data(
298 channel,
299 CryptoVec::from(
300 b"That invite has already been used.\r\nEnter invite code: "
301 .to_vec(),
302 ),
303 );
304 self.signup_state = Some(SignupState::WaitingForInvite { username });
305 }
306 _ => {
307 session.data(
308 channel,
309 CryptoVec::from(b"Something went wrong. Try again later.\r\n".to_vec()),
310 );
311 session.exit_status_request(channel, 1);
312 session.eof(channel);
313 session.close(channel);
314 }
315 },
316 }
317 }
318 None => {}
319 }
320 }
321 }
322
323 #[async_trait]
324 impl server::Handler for SshHandler {
325 type Error = russh::Error;
326
327 async fn auth_publickey(
328 &mut self,
329 _user: &str,
330 key: &russh::keys::key::PublicKey,
331 ) -> Result<Auth, Self::Error> {
332 // Only accept ed25519 keys — reject others so SSH client tries next key
333 if key.name() != "ssh-ed25519" {
334 return Ok(Auth::Reject { proceed_with_methods: None });
335 }
336
337 // Store key in OpenSSH format for potential account creation
338 self.auth_key_openssh = Some(format!("{} {}", key.name(), key.public_key_base64()));
339
340 // Check if this key belongs to a registered user
341 if let Some((id, username)) = auth::lookup_user_by_pubkey(&self.pool, key).await {
342 self.user_id = Some(id);
343 self.username = Some(username);
344 }
345
346 // Accept ed25519 keys — unregistered ones get interactive signup
347 Ok(Auth::Accept)
348 }
349
350 async fn channel_open_session(
351 &mut self,
352 _channel: Channel<Msg>,
353 _session: &mut Session,
354 ) -> Result<bool, Self::Error> {
355 Ok(true)
356 }
357
358 async fn shell_request(
359 &mut self,
360 channel_id: ChannelId,
361 session: &mut Session,
362 ) -> Result<(), Self::Error> {
363 if let Some(ref username) = self.username {
364 let msg = format!(
365 "Hi {}! You've authenticated, but openhub doesn't provide shell access.\r\n",
366 username
367 );
368 session.data(channel_id, CryptoVec::from(msg.into_bytes()));
369 session.exit_status_request(channel_id, 0);
370 session.eof(channel_id);
371 session.close(channel_id);
372 } else {
373 // Unregistered key — start interactive signup
374 self.signup_state = Some(SignupState::WaitingForUsername);
375 let welcome =
376 "\r\nWelcome to openhub! Your SSH key isn't registered yet.\r\n\r\nChoose a username: ";
377 session.data(
378 channel_id,
379 CryptoVec::from(welcome.as_bytes().to_vec()),
380 );
381 }
382 Ok(())
383 }
384
385 async fn exec_request(
386 &mut self,
387 channel_id: ChannelId,
388 data: &[u8],
389 session: &mut Session,
390 ) -> Result<(), Self::Error> {
391 let username = match &self.username {
392 Some(u) => u.clone(),
393 None => {
394 session.data(
395 channel_id,
396 CryptoVec::from(
397 b"Your SSH key isn't registered. Run 'ssh git@<host>' to create an account.\r\n"
398 .to_vec(),
399 ),
400 );
401 session.exit_status_request(channel_id, 1);
402 session.eof(channel_id);
403 session.close(channel_id);
404 return Ok(());
405 }
406 };
407
408 let cmd_str = String::from_utf8_lossy(data);
409 let (git_cmd, repo_path) = match parse_git_command(&cmd_str) {
410 Some(parsed) => parsed,
411 None => {
412 if self
413 .handle_openhub_command(&cmd_str, channel_id, session)
414 .await
415 {
416 return Ok(());
417 }
418 session.data(channel_id, CryptoVec::from(b"Invalid command.\r\n".to_vec()));
419 session.close(channel_id);
420 return Ok(());
421 }
422 };
423
424 // Parse owner/repo from path like "/user/repo.git" or "'user/repo.git'"
425 let path = repo_path.trim_matches(|c| c == '\'' || c == '"');
426 let path = path.trim_start_matches('/');
427 let mut parts = path.splitn(2, '/');
428 let owner = match parts.next() {
429 Some(o) if !o.is_empty() => o.to_string(),
430 _ => {
431 session.data(channel_id, CryptoVec::from(b"Invalid repository path.\r\n".to_vec()));
432 session.close(channel_id);
433 return Ok(());
434 }
435 };
436 let repo_git = match parts.next() {
437 Some(r) if !r.is_empty() => r.to_string(),
438 _ => {
439 session.data(channel_id, CryptoVec::from(b"Invalid repository path.\r\n".to_vec()));
440 session.close(channel_id);
441 return Ok(());
442 }
443 };
444
445 let repo_name = repo_git.trim_end_matches(".git");
446
447 // Verify repo exists
448 if !repos::repo_exists(&self.pool, &owner, repo_name).await {
449 let msg = format!("Repository {}/{} not found.\r\n", owner, repo_name);
450 session.data(channel_id, CryptoVec::from(msg.into_bytes()));
451 session.close(channel_id);
452 return Ok(());
453 }
454
455 // For push (git-receive-pack), verify the user owns the repo
456 if git_cmd == "git-receive-pack" && username != owner {
457 session.data(channel_id, CryptoVec::from(b"Permission denied.\r\n".to_vec()));
458 session.close(channel_id);
459 return Ok(());
460 }
461
462 // Resolve full repo path on disk
463 let full_path = format!("{}/{}/{}.git", self.cfg.repos_root, owner, repo_name);
464
465 // Spawn git subprocess
466 let mut child = match Command::new(&git_cmd)
467 .arg(&full_path)
468 .env("MAX_REPO_BYTES", self.cfg.max_repo_bytes.to_string())
469 .stdin(Stdio::piped())
470 .stdout(Stdio::piped())
471 .stderr(Stdio::piped())
472 .spawn()
473 {
474 Ok(c) => c,
475 Err(e) => {
476 let msg = format!("Failed to start {}: {}\r\n", git_cmd, e);
477 session.data(channel_id, CryptoVec::from(msg.into_bytes()));
478 session.close(channel_id);
479 return Ok(());
480 }
481 };
482
483 let stdout = child.stdout.take().unwrap();
484 let stderr = child.stderr.take().unwrap();
485 self.child_stdin = child.stdin.take();
486
487 let handle = session.handle();
488
489 // Pipe stdout to channel data
490 let handle_out = handle.clone();
491 let stdout_task = tokio::spawn(async move {
492 let mut stdout = stdout;
493 let mut buf = vec![0u8; 32768];
494 loop {
495 match stdout.read(&mut buf).await {
496 Ok(0) => break,
497 Ok(n) => {
498 let data = CryptoVec::from(buf[..n].to_vec());
499 if handle_out.data(channel_id, data).await.is_err() {
500 break;
501 }
502 }
503 Err(_) => break,
504 }
505 }
506 });
507
508 // Pipe stderr to channel extended data (type 1 = stderr)
509 let handle_err = handle.clone();
510 let stderr_task = tokio::spawn(async move {
511 let mut stderr = stderr;
512 let mut buf = vec![0u8; 32768];
513 loop {
514 match stderr.read(&mut buf).await {
515 Ok(0) => break,
516 Ok(n) => {
517 let data = CryptoVec::from(buf[..n].to_vec());
518 if handle_err.extended_data(channel_id, 1, data).await.is_err() {
519 break;
520 }
521 }
522 Err(_) => break,
523 }
524 }
525 });
526
527 // Wait for process exit, then send exit status and close
528 tokio::spawn(async move {
529 let status = child.wait().await;
530 // Wait for stdout/stderr to be fully piped before closing
531 let _ = stdout_task.await;
532 let _ = stderr_task.await;
533
534 let code = status
535 .map(|s| s.code().unwrap_or(1) as u32)
536 .unwrap_or(1);
537 let _ = handle.exit_status_request(channel_id, code).await;
538 let _ = handle.eof(channel_id).await;
539 let _ = handle.close(channel_id).await;
540 });
541
542 Ok(())
543 }
544
545 async fn data(
546 &mut self,
547 channel: ChannelId,
548 data: &[u8],
549 session: &mut Session,
550 ) -> Result<(), Self::Error> {
551 // Interactive signup mode — handle terminal input
552 if self.signup_state.is_some() {
553 for &byte in data {
554 match byte {
555 // Backspace / Delete
556 0x7f | 0x08 => {
557 if !self.input_buf.is_empty() {
558 self.input_buf.pop();
559 session.data(channel, CryptoVec::from(b"\x08 \x08".to_vec()));
560 }
561 }
562 // Ctrl+C / Ctrl+D — abort
563 0x03 | 0x04 => {
564 session.data(channel, CryptoVec::from(b"\r\nAborted.\r\n".to_vec()));
565 session.exit_status_request(channel, 1);
566 session.eof(channel);
567 session.close(channel);
568 return Ok(());
569 }
570 // Enter
571 b'\r' | b'\n' => {
572 session.data(channel, CryptoVec::from(b"\r\n".to_vec()));
573 self.process_signup_input(channel, session).await;
574 }
575 // Printable ASCII
576 byte if byte >= 0x20 && byte < 0x7f => {
577 self.input_buf.push(byte as char);
578 session.data(channel, CryptoVec::from(vec![byte]));
579 }
580 _ => {}
581 }
582 }
583 return Ok(());
584 }
585
586 // Normal mode: forward to subprocess stdin
587 if let Some(ref mut stdin) = self.child_stdin {
588 let _ = stdin.write_all(data).await;
589 }
590 Ok(())
591 }
592
593 async fn channel_eof(
594 &mut self,
595 _channel: ChannelId,
596 _session: &mut Session,
597 ) -> Result<(), Self::Error> {
598 // Drop stdin to signal EOF to the subprocess
599 self.child_stdin.take();
600 Ok(())
601 }
602 }
603
604 fn parse_git_command(cmd: &str) -> Option<(String, String)> {
605 let cmd = cmd.trim();
606 for prefix in &["git-upload-pack", "git-receive-pack"] {
607 if cmd.starts_with(prefix) {
608 let rest = cmd[prefix.len()..].trim();
609 if !rest.is_empty() {
610 return Some((prefix.to_string(), rest.to_string()));
611 }
612 }
613 }
614 None
615 }