Skip to content
Clone

feat(gitssh): add gitssh crate (server, handler, tests)

zhenyi committed
parent b861633 commit 8351cde Browse files

11 files changed

+1512 −0

File tree 11
Added lib/gitssh/handler/auth.rs View lib/gitssh/handler/auth.rs
@@ -0,0 +1,116 @@
1use std::str::FromStr;
2
3use russh::server::Auth;
4use tokio::task;
5
6use db::conn::sea_orm::{EntityTrait, QueryFilter, Set};
7use db::user::user_ssh_keys::{self, Entity as SshKeyEntity};
8use db::user::users::{self, Entity as UserEntity};
9use uuid::Uuid;
10
11use crate::handler::GitSshHandler;
12
13pub struct SshKeyUser {
14 pub user: users::Model,
15 pub key_id: Uuid,
16}
17
18impl GitSshHandler {
19 /// Find the user whose stored SSH key matches the presented public key.
20 /// The workspace stores OpenSSH public key text (content column) and a
21 /// sha256_hex(content bytes) fingerprint; gitssh must parse the stored
22 /// content and compare with the presented key directly.
23 async fn find_user_by_public_key(
24 &self,
25 username: &str,
26 public_key: &ssh_key::PublicKey,
27 ) -> Result<Option<SshKeyUser>, db::conn::SqlxError> {
28 let db = self.service.auth.db.reader();
29 let Some(user) = UserEntity::find()
30 .filter(users::COLUMN.lower_name.eq(username.to_lowercase()))
31 .one(&db)
32 .await?
33 else {
34 return Ok(None);
35 };
36 if !user.is_active {
37 return Ok(None);
38 }
39 let keys = SshKeyEntity::find()
40 .filter(user_ssh_keys::COLUMN.user_id.eq(user.uid))
41 .all(&db)
42 .await?;
43 let presented = public_key.to_string();
44 for key in keys {
45 let matched = ssh_key::PublicKey::from_str(key.content.trim())
46 .ok()
47 .map_or_else(
48 || key.content.trim() == presented.trim(),
49 |p| p == *public_key,
50 );
51 if matched {
52 return Ok(Some(SshKeyUser {
53 user,
54 key_id: key.id,
55 }));
56 }
57 }
58 Ok(None)
59 }
60
61 fn update_key_last_used(&self, key_id: Uuid) {
62 let db = self.service.auth.db.writer();
63 let now = time::OffsetDateTime::now_utc();
64 task::spawn(async move {
65 let _ = SshKeyEntity::update_many()
66 .set(user_ssh_keys::ActiveModel {
67 last_used_at: Set(Some(now)),
68 ..Default::default()
69 })
70 .filter(user_ssh_keys::COLUMN.id.eq(key_id))
71 .exec(&db)
72 .await;
73 });
74 }
75
76 pub async fn auth_publickey_offered_impl(
77 &mut self,
78 user: &str,
79 public_key: &ssh_key::PublicKey,
80 ) -> Result<Auth, russh::Error> {
81 if user.is_empty() || public_key.to_string().len() < 32 {
82 return Ok(Auth::reject());
83 }
84 match self.find_user_by_public_key(user, public_key).await {
85 Ok(Some(_)) => Ok(Auth::Accept),
86 Ok(None) => Ok(Auth::reject()),
87 Err(e) => {
88 tracing::warn!("ssh auth lookup failed: {e}");
89 Err(russh::Error::NotAuthenticated)
90 }
91 }
92 }
93
94 pub async fn auth_publickey_impl(
95 &mut self,
96 user: &str,
97 public_key: &ssh_key::PublicKey,
98 ) -> Result<Auth, russh::Error> {
99 if user.is_empty() || public_key.to_string().len() < 32 {
100 return Ok(Auth::reject());
101 }
102 match self.find_user_by_public_key(user, public_key).await {
103 Ok(Some(found)) => {
104 let key_id = found.key_id;
105 self.operator = Some(found.user);
106 self.update_key_last_used(key_id);
107 Ok(Auth::Accept)
108 }
109 Ok(None) => Ok(Auth::reject()),
110 Err(e) => {
111 tracing::warn!("ssh auth lookup failed: {e}");
112 Err(russh::Error::NotAuthenticated)
113 }
114 }
115 }
116}
Added lib/gitssh/handler/channel.rs View lib/gitssh/handler/channel.rs
@@ -0,0 +1,137 @@
1use bytes::Bytes;
2use russh::ChannelId;
3use russh::server::Session;
4
5use crate::handler::GitSshHandler;
6
7impl GitSshHandler {
8 /// Append incoming client data to the per-channel buffer. All buffered
9 /// bytes are flushed to the git subprocess in a single chunk on EOF.
10 pub async fn data_impl(&mut self, channel: ChannelId, data: &[u8]) -> Result<(), russh::Error> {
11 let state = self.channels.entry(channel).or_default();
12 state.buffer.extend_from_slice(data);
13 Ok(())
14 }
15
16 pub async fn channel_close_impl(&mut self, channel: ChannelId) {
17 self.channels.remove(&channel);
18 }
19
20 /// On EOF, dispatch the buffered bytes to the git subprocess
21 /// (asynchronously) and send the result back over the channel.
22 pub async fn channel_eof_impl(
23 &mut self,
24 channel: ChannelId,
25 session: &mut Session,
26 ) -> Result<(), russh::Error> {
27 let Some(state) = self.channels.get_mut(&channel) else {
28 let _ = session.close(channel);
29 let _ = session.flush();
30 return Ok(());
31 };
32 let op = state.git_op.take();
33 let buffer = std::mem::take(&mut state.buffer);
34 let push_coordinator = self.service.push_coordinator();
35
36 let handle = session.handle();
37 tokio::spawn(async move {
38 let is_write = op.as_ref().is_some_and(|o| o.is_write());
39 let push_key = op
40 .as_ref()
41 .map(|o| format!("git_op:{}", o.bare().display()))
42 .unwrap_or_default();
43
44 let run = async { run_git(op.as_ref(), &buffer, &handle, channel).await };
45 let result = if is_write {
46 push_coordinator
47 .run(&push_key, async {
48 run.await
49 .map_err(|e| gitsmart::SmartError::Internal(e.to_string()))
50 })
51 .await
52 .map_err(crate::error::SshError::from)
53 } else {
54 run.await
55 };
56 let code = match &result {
57 Ok(_) => 0,
58 Err(_) => 1,
59 };
60 let _ = handle.exit_status_request(channel, code).await;
61 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
62 let _ = handle.eof(channel).await;
63 let _ = handle.close(channel).await;
64 });
65
66 Ok(())
67 }
68
69 pub async fn shell_request_impl(
70 &mut self,
71 channel: ChannelId,
72 session: &mut Session,
73 ) -> Result<(), russh::Error> {
74 let msg = Bytes::from_static(
75 b"Welcome to GitSsh. This is a git-only server; shell access is disabled.\r\n",
76 );
77 let _ = session.data(channel, msg);
78 let _ = session.exit_status_request(channel, 0);
79 let _ = session.eof(channel);
80 let _ = session.close(channel);
81 let _ = session.flush();
82 Ok(())
83 }
84}
85
86/// Run the git subprocess once: write the buffered input, read stdout, and
87/// forward it back to the client.
88async fn run_git(
89 op: Option<&crate::handler::git_op::GitOperation>,
90 input: &[u8],
91 handle: &russh::server::Handle,
92 channel: russh::ChannelId,
93) -> Result<(), crate::error::SshError> {
94 let Some(op) = op else {
95 return Ok(());
96 };
97 let mut cmd = tokio::process::Command::new("git");
98 cmd.current_dir(op.bare())
99 .arg(op.service_arg())
100 .arg("--stateless-rpc")
101 .env("GIT_DIR", op.bare())
102 .env("GIT_TERMINAL_PROMPT", "0")
103 .stdin(std::process::Stdio::piped())
104 .stdout(std::process::Stdio::piped())
105 .stderr(std::process::Stdio::piped());
106
107 let mut child = cmd.spawn().map_err(crate::error::SshError::Io)?;
108 use tokio::io::AsyncWriteExt;
109 if let Some(mut stdin) = child.stdin.take() {
110 stdin
111 .write_all(input)
112 .await
113 .map_err(crate::error::SshError::Io)?;
114 let _ = stdin.shutdown().await;
115 }
116 let output = child
117 .wait_with_output()
118 .await
119 .map_err(crate::error::SshError::Io)?;
120 if !output.stdout.is_empty() {
121 let _ = handle.data(channel, Bytes::from(output.stdout)).await;
122 }
123 if !output.stderr.is_empty() && !output.status.success() {
124 let _ = handle
125 .extended_data(channel, 1, Bytes::from(output.stderr))
126 .await;
127 }
128 if output.status.success() {
129 Ok(())
130 } else {
131 Err(crate::error::SshError::Internal(format!(
132 "git {} failed: exit {:?}",
133 op.service_arg(),
134 output.status.code()
135 )))
136 }
137}
Added lib/gitssh/handler/command.rs View lib/gitssh/handler/command.rs
@@ -0,0 +1,169 @@
1use bytes::Bytes;
2use russh::ChannelId;
3use russh::server::Session;
4
5use gitauth::ResolvedRepo;
6use repo::actor::{ActorContext, RepoAccess};
7
8use crate::error::SshError;
9use crate::git_service::{GitService, parse_git_command, parse_repo_path, sanitize_for_log};
10use crate::handler::GitSshHandler;
11use crate::handler::git_op::GitOperation;
12
13impl GitSshHandler {
14 pub async fn exec_request_impl(
15 &mut self,
16 channel: ChannelId,
17 data: &[u8],
18 session: &mut Session,
19 ) -> Result<(), russh::Error> {
20 let cmd = std::str::from_utf8(data)
21 .map_err(|_| invalid_command_error("Invalid command encoding", session, channel))?;
22 let cmd = cmd.trim();
23 tracing::info!(
24 "ssh exec from {:?}: {}",
25 self.client_addr,
26 sanitize_for_log(cmd)
27 );
28
29 let Some((service, raw_path)) = parse_git_command(cmd) else {
30 return Err(invalid_command_error(
31 "Invalid git command",
32 session,
33 channel,
34 ));
35 };
36 let Some((space, repo)) = parse_repo_path(raw_path) else {
37 return Err(invalid_command_error(
38 "Invalid repository path",
39 session,
40 channel,
41 ));
42 };
43
44 let actor = match &self.operator {
45 Some(u) => ActorContext::for_user(u.uid),
46 None => ActorContext::anonymous(),
47 };
48 let required = if service.is_write() {
49 RepoAccess::Write
50 } else {
51 RepoAccess::Read
52 };
53 let resolved: ResolvedRepo = match self
54 .service
55 .auth
56 .authorize(space, repo, &actor, required)
57 .await
58 {
59 Ok(r) => r,
60 Err(e) => {
61 let msg = auth_error_message(&e);
62 tracing::warn!("ssh {service:?} {space}/{repo} denied: {msg}");
63 return Err(invalid_command_error(msg.as_str(), session, channel));
64 }
65 };
66
67 let bare = self.service.bare_path(&resolved);
68 if !bare.is_dir() {
69 let msg = format!("fatal: repository '{space}/{repo}' does not exist");
70 return Err(invalid_command_error(msg.as_str(), session, channel));
71 }
72
73 // Run `git <service> --advertise-refs` to produce the initial advertisement
74 // and send it to the client. Then channel_success tells the client it can
75 // begin sending its half of the protocol. The actual request bytes are
76 // buffered in `state.buffer` and flushed to a one-shot git subprocess on EOF.
77 // Advertise failure is non-fatal — we still send channel_success so the
78 // client can proceed; the real work happens on channel_eof.
79 let adv = run_advertise(&bare, service).await.unwrap_or_default();
80 if !adv.is_empty() {
81 let _ = session.data(channel, Bytes::from(adv));
82 }
83 let _ = session.channel_success(channel);
84 let _ = session.flush();
85
86 let op = match service {
87 GitService::UploadPack => GitOperation::UploadPack { bare: bare.clone() },
88 _ => GitOperation::ReceivePack { bare: bare.clone() },
89 };
90 let state = self.channels.entry(channel).or_default();
91 state.git_op = Some(op);
92
93 Ok(())
94 }
95}
96
97fn invalid_command_error(msg: &str, session: &mut Session, channel: ChannelId) -> russh::Error {
98 let _ = session.channel_failure(channel);
99 let _ = session.exit_status_request(channel, 1);
100 let _ = session.close(channel);
101 let _ = session.flush();
102 russh::Error::IO(std::io::Error::new(
103 std::io::ErrorKind::InvalidData,
104 msg.to_string(),
105 ))
106}
107
108fn auth_error_message(e: &gitauth::GitAuthError) -> String {
109 match e {
110 gitauth::GitAuthError::BadRequest(m)
111 | gitauth::GitAuthError::Unauthorized(m)
112 | gitauth::GitAuthError::Forbidden(m)
113 | gitauth::GitAuthError::NotFound(m)
114 | gitauth::GitAuthError::Conflict(m) => m.clone(),
115 _ => "authentication or authorization failed".to_string(),
116 }
117}
118
119/// Run `git <service> --advertise-refs` and return its stdout bytes.
120async fn run_advertise(
121 bare: &std::path::PathBuf,
122 service: GitService,
123) -> Result<Vec<u8>, SshError> {
124 let mut cmd = tokio::process::Command::new("git");
125 cmd.current_dir(bare)
126 .arg(service.as_subcommand())
127 .arg("--advertise-refs")
128 .env("GIT_DIR", bare)
129 .env("GIT_TERMINAL_PROMPT", "0")
130 .stdout(std::process::Stdio::piped())
131 .stderr(std::process::Stdio::piped());
132 let mut child = cmd.spawn().map_err(SshError::Io)?;
133 // Drain stderr concurrently to avoid a deadlock on a full pipe.
134 let stderr_task = if let Some(stderr) = child.stderr.take() {
135 Some(tokio::spawn(async move {
136 use tokio::io::AsyncReadExt;
137 let mut s = stderr;
138 let mut buf = Vec::new();
139 let _ = s.read_to_end(&mut buf).await;
140 buf
141 }))
142 } else {
143 None
144 };
145 let mut stdout = child
146 .stdout
147 .take()
148 .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
149 let mut out = Vec::new();
150 use tokio::io::AsyncReadExt;
151 stdout.read_to_end(&mut out).await.map_err(SshError::Io)?;
152 let status = child.wait().await.map_err(SshError::Io)?;
153 let stderr_bytes = match stderr_task {
154 Some(t) => t.await.unwrap_or_default(),
155 None => Vec::new(),
156 };
157 if !status.success() {
158 return Err(SshError::Internal(format!(
159 "git {} --advertise-refs failed: {}",
160 service.as_subcommand(),
161 String::from_utf8_lossy(&stderr_bytes)
162 )));
163 }
164 Ok(out)
165}
166
167// Suppress unused import warnings while keeping the door open for future expansion.
168#[allow(dead_code)]
169fn _keep_module_in_scope() {}
Added lib/gitssh/handler/git_op.rs View lib/gitssh/handler/git_op.rs
@@ -0,0 +1,26 @@
1use std::path::PathBuf;
2
3#[derive(Clone, Debug)]
4pub enum GitOperation {
5 UploadPack { bare: PathBuf },
6 ReceivePack { bare: PathBuf },
7}
8
9impl GitOperation {
10 pub fn bare(&self) -> &PathBuf {
11 match self {
12 Self::UploadPack { bare } | Self::ReceivePack { bare } => bare,
13 }
14 }
15
16 pub fn is_write(&self) -> bool {
17 matches!(self, Self::ReceivePack { .. })
18 }
19
20 pub fn service_arg(&self) -> &'static str {
21 match self {
22 Self::UploadPack { .. } => "upload-pack",
23 Self::ReceivePack { .. } => "receive-pack",
24 }
25 }
26}
Added lib/gitssh/handler/mod.rs View lib/gitssh/handler/mod.rs
@@ -0,0 +1,202 @@
1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use russh::server::{Auth, Msg, Session};
7use russh::{Channel, ChannelId};
8use tokio::sync::OwnedSemaphorePermit;
9
10use db::user::users;
11
12use crate::config::GitSshConfig;
13use crate::handler::git_op::GitOperation;
14use crate::service::GitSshService;
15
16pub(crate) mod git_op;
17
18mod auth;
19mod channel;
20mod command;
21
22#[derive(Default)]
23pub struct ChannelState {
24 pub buffer: Vec<u8>,
25 pub git_op: Option<GitOperation>,
26}
27
28pub struct GitSshHandler {
29 pub service: Arc<GitSshService>,
30 pub client_addr: Option<SocketAddr>,
31 #[allow(dead_code)]
32 pub session_permit: Option<OwnedSemaphorePermit>,
33 pub operator: Option<users::Model>,
34 pub channels: HashMap<ChannelId, ChannelState>,
35}
36
37impl GitSshHandler {
38 pub fn new(
39 service: Arc<GitSshService>,
40 client_addr: Option<SocketAddr>,
41 session_permit: Option<OwnedSemaphorePermit>,
42 ) -> Self {
43 Self {
44 service,
45 client_addr,
46 session_permit,
47 operator: None,
48 channels: HashMap::new(),
49 }
50 }
51
52 pub fn config(&self) -> &GitSshConfig {
53 &self.service.config
54 }
55}
56
57impl russh::server::Handler for GitSshHandler {
58 type Error = russh::Error;
59
60 async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
61 Ok(Auth::reject())
62 }
63
64 async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
65 Ok(Auth::reject())
66 }
67
68 async fn auth_publickey_offered(
69 &mut self,
70 user: &str,
71 public_key: &ssh_key::PublicKey,
72 ) -> Result<Auth, Self::Error> {
73 self.auth_publickey_offered_impl(user, public_key).await
74 }
75
76 async fn auth_publickey(
77 &mut self,
78 user: &str,
79 public_key: &ssh_key::PublicKey,
80 ) -> Result<Auth, Self::Error> {
81 self.auth_publickey_impl(user, public_key).await
82 }
83
84 async fn auth_openssh_certificate(
85 &mut self,
86 _user: &str,
87 _certificate: &ssh_key::Certificate,
88 ) -> Result<Auth, Self::Error> {
89 Err(russh::Error::NotAuthenticated)
90 }
91
92 async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> {
93 Ok(self.config().banner.clone())
94 }
95
96 async fn channel_open_session(
97 &mut self,
98 _channel: Channel<Msg>,
99 reply: russh::server::ChannelOpenHandle,
100 session: &mut Session,
101 ) -> Result<(), Self::Error> {
102 reply.accept().await;
103 let _ = session.flush();
104 Ok(())
105 }
106
107 async fn channel_close(
108 &mut self,
109 channel: ChannelId,
110 _session: &mut Session,
111 ) -> Result<(), Self::Error> {
112 self.channel_close_impl(channel).await;
113 Ok(())
114 }
115
116 async fn channel_eof(
117 &mut self,
118 channel: ChannelId,
119 session: &mut Session,
120 ) -> Result<(), Self::Error> {
121 self.channel_eof_impl(channel, session).await
122 }
123
124 async fn data(
125 &mut self,
126 channel: ChannelId,
127 data: &[u8],
128 _session: &mut Session,
129 ) -> Result<(), Self::Error> {
130 self.data_impl(channel, data).await
131 }
132
133 async fn exec_request(
134 &mut self,
135 channel: ChannelId,
136 data: &[u8],
137 session: &mut Session,
138 ) -> Result<(), Self::Error> {
139 self.exec_request_impl(channel, data, session).await
140 }
141
142 async fn pty_request(
143 &mut self,
144 _channel: ChannelId,
145 _term: &str,
146 _col_width: u32,
147 _row_height: u32,
148 _pix_width: u32,
149 _pix_height: u32,
150 _modes: &[(russh::Pty, u32)],
151 session: &mut Session,
152 ) -> Result<(), Self::Error> {
153 let _ = session.flush();
154 Ok(())
155 }
156
157 async fn shell_request(
158 &mut self,
159 channel: ChannelId,
160 session: &mut Session,
161 ) -> Result<(), Self::Error> {
162 self.shell_request_impl(channel, session).await
163 }
164
165 async fn subsystem_request(
166 &mut self,
167 _channel: ChannelId,
168 _name: &str,
169 session: &mut Session,
170 ) -> Result<(), Self::Error> {
171 let _ = session.flush();
172 Ok(())
173 }
174
175 async fn env_request(
176 &mut self,
177 _channel: ChannelId,
178 _variable_name: &str,
179 _variable_value: &str,
180 session: &mut Session,
181 ) -> Result<(), Self::Error> {
182 let _ = session.flush();
183 Ok(())
184 }
185
186 async fn window_change_request(
187 &mut self,
188 _channel: ChannelId,
189 _col_width: u32,
190 _row_height: u32,
191 _pix_width: u32,
192 _pix_height: u32,
193 session: &mut Session,
194 ) -> Result<(), Self::Error> {
195 let _ = session.flush();
196 Ok(())
197 }
198}
199
200/// Unused PathBuf import kept for symmetry with sibling modules that may grow.
201#[allow(dead_code)]
202type _PathBuf = PathBuf;
Added lib/gitssh/tests/ssh_roundtrip.rs View lib/gitssh/tests/ssh_roundtrip.rs
@@ -0,0 +1,445 @@
1use std::net::SocketAddr;
2use std::os::unix::fs::PermissionsExt;
3use std::path::Path;
4use std::sync::Arc;
5use std::time::Duration;
6
7use db::channel::channel_members;
8use db::channel::channels;
9use db::conn::Db;
10use db::conn::sea_orm::{
11 ActiveModelTrait, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, EntityTrait,
12 Schema, Set,
13};
14use db::repo::repos;
15use db::space::spaces;
16use db::user::user_ssh_keys;
17use db::user::users;
18use gitauth::GitAuthService;
19use gitssh::server::{generate_host_key, serve_on_listener};
20use gitssh::{GitSshConfig, GitSshService};
21use jwt::{JwtSettings, JwtState, SigningKeyCache};
22use sha2::{Digest, Sha256};
23use tempfile::TempDir;
24use time::OffsetDateTime;
25use tokio::sync::RwLock;
26use uuid::Uuid;
27
28fn now() -> OffsetDateTime {
29 OffsetDateTime::now_utc()
30}
31
32fn sha256_hex(data: &[u8]) -> String {
33 let mut hasher = Sha256::new();
34 hasher.update(data);
35 let digest = hasher.finalize();
36 let mut out = String::with_capacity(64);
37 for b in digest {
38 out.push_str(&format!("{b:02x}"));
39 }
40 out
41}
42
43async fn create_table<E: EntityTrait>(db: &DatabaseConnection) {
44 let stmt = Schema::new(DatabaseBackend::Sqlite).create_table_from_entity(E::default());
45 db.execute(&stmt).await.expect("create table");
46}
47
48async fn setup_db() -> DatabaseConnection {
49 let db = Database::connect("sqlite::memory:")
50 .await
51 .expect("connect sqlite memory");
52 create_table::<users::Entity>(&db).await;
53 create_table::<spaces::Entity>(&db).await;
54 create_table::<channels::Entity>(&db).await;
55 create_table::<channel_members::Entity>(&db).await;
56 create_table::<db::space::space_members::Entity>(&db).await;
57 create_table::<db::space::space_roles::Entity>(&db).await;
58 create_table::<db::space::space_member_roles::Entity>(&db).await;
59 create_table::<db::space::space_role_permissions::Entity>(&db).await;
60 create_table::<db::repo::repo_collaborations::Entity>(&db).await;
61 create_table::<repos::Entity>(&db).await;
62 create_table::<user_ssh_keys::Entity>(&db).await;
63 db
64}
65
66async fn insert_user(db: &impl ConnectionTrait, uid: Uuid, username: &str) {
67 users::ActiveModel {
68 uid: Set(uid),
69 username: Set(username.to_string()),
70 lower_name: Set(username.to_lowercase()),
71 display_name: Set(username.to_string()),
72 avatar: Set(String::new()),
73 is_active: Set(true),
74 is_admin: Set(false),
75 is_restricted: Set(false),
76 prohibit_login: Set(false),
77 visibility: Set(users::ProfileVisibility::Public),
78 bot: Set(false),
79 system: Set(false),
80 totp_enabled: Set(false),
81 version: Set(1),
82 passwd_hash: Set(None),
83 created_at: Set(now()),
84 updated_at: Set(now()),
85 ..Default::default()
86 }
87 .insert(db)
88 .await
89 .expect("insert user");
90}
91
92async fn insert_space(db: &impl ConnectionTrait, id: Uuid, lower_name: &str, owner_id: Uuid) {
93 spaces::ActiveModel {
94 id: Set(id),
95 lower_name: Set(lower_name.to_string()),
96 name: Set(lower_name.to_string()),
97 owner_id: Set(owner_id),
98 public: Set(true),
99 is_active: Set(true),
100 allow_join: Set(true),
101 allow_invite: Set(true),
102 member_count: Set(0),
103 allow_join_level: Set(spaces::AllowJoinLevel::None),
104 member_must_enable_2fa: Set(false),
105 created_at: Set(now()),
106 updated_at: Set(now()),
107 ..Default::default()
108 }
109 .insert(db)
110 .await
111 .expect("insert space");
112}
113
114async fn insert_channel(
115 db: &impl ConnectionTrait,
116 id: Uuid,
117 space_id: Uuid,
118 name: &str,
119 created_by: Uuid,
120) {
121 channels::ActiveModel {
122 id: Set(id),
123 space_id: Set(Some(space_id)),
124 name: Set(name.to_string()),
125 position: Set(0),
126 is_archived: Set(false),
127 created_at: Set(now()),
128 updated_at: Set(now()),
129 created_by: Set(created_by),
130 ..Default::default()
131 }
132 .insert(db)
133 .await
134 .expect("insert channel");
135}
136
137async fn insert_repo(
138 db: &impl ConnectionTrait,
139 id: Uuid,
140 channel_id: Uuid,
141 name: &str,
142 is_private: bool,
143) {
144 repos::ActiveModel {
145 id: Set(id),
146 channel_id: Set(channel_id),
147 lower_name: Set(name.to_lowercase()),
148 name: Set(name.to_string()),
149 default_branch: Set("main".to_string()),
150 nums_watch: Set(0),
151 nums_star: Set(0),
152 nums_pr: Set(0),
153 nums_commits: Set(0),
154 nums_branches: Set(0),
155 nums_tags: Set(0),
156 enable_lfs: Set(true),
157 is_private: Set(is_private),
158 is_archived: Set(false),
159 size: Set(0),
160 git_size: Set(0),
161 lfs_size: Set(0),
162 allow_rebase: Set(true),
163 allow_rebase_explicit: Set(true),
164 allow_squash_merge: Set(true),
165 allow_merge_commits: Set(true),
166 created_at: Set(now()),
167 object_format: Set("sha1".to_string()),
168 ..Default::default()
169 }
170 .insert(db)
171 .await
172 .expect("insert repo");
173}
174
175async fn insert_channel_member(
176 db: &impl ConnectionTrait,
177 channel_id: Uuid,
178 user_id: Uuid,
179 role: channel_members::ChannelMemberRole,
180) {
181 channel_members::ActiveModel {
182 channel_id: Set(channel_id),
183 user_id: Set(user_id),
184 role: Set(role),
185 muted: Set(false),
186 joined_at: Set(now()),
187 ..Default::default()
188 }
189 .insert(db)
190 .await
191 .expect("insert channel member");
192}
193
194async fn insert_ssh_key(db: &impl ConnectionTrait, user_id: Uuid, content: &str) {
195 let fingerprint = sha256_hex(content.as_bytes());
196 let am: user_ssh_keys::ActiveModel = user_ssh_keys::ActiveModel::builder()
197 .set_id(Uuid::new_v4())
198 .set_user_id(user_id)
199 .set_key_type(user_ssh_keys::SshKeyType::SshEd25519)
200 .set_fingerprint(fingerprint)
201 .set_name("test-key".to_string())
202 .set_content(content.to_string())
203 .set_created_at(now())
204 .set_updated_at(now())
205 .set_last_used_at(None::<time::OffsetDateTime>)
206 .into();
207 am.insert(db).await.expect("insert ssh key");
208}
209
210async fn git(dir: &Path, args: &[&str]) -> String {
211 let out = tokio::process::Command::new("git")
212 .args(args)
213 .current_dir(dir)
214 .env("GIT_TERMINAL_PROMPT", "0")
215 .env("GIT_SSH_COMMAND", "")
216 .output()
217 .await
218 .expect("run git");
219 assert!(
220 out.status.success(),
221 "git {:?} failed: {}",
222 args,
223 String::from_utf8_lossy(&out.stderr)
224 );
225 String::from_utf8_lossy(&out.stdout).into_owned()
226}
227
228struct TestServer {
229 _tmp: TempDir,
230 addr: SocketAddr,
231 port: u16,
232 git_root: std::path::PathBuf,
233 channel_id: Uuid,
234 repo_id: Uuid,
235 ssh_env: String,
236}
237
238async fn start_server() -> TestServer {
239 let tmp = TempDir::new().expect("tempdir");
240 let root = tmp.path().to_path_buf();
241
242 let git_root = root.join("git");
243 std::fs::create_dir_all(&git_root).expect("git root");
244
245 let conn = setup_db().await;
246 let db = Db::from_connection(conn.clone());
247
248 let alice = Uuid::new_v4();
249 let space_id = Uuid::new_v4();
250 let channel_id = Uuid::new_v4();
251 let repo_id = Uuid::new_v4();
252
253 insert_user(&conn, alice, "alice").await;
254 insert_space(&conn, space_id, "acme", alice).await;
255 insert_channel(&conn, channel_id, space_id, "dev", alice).await;
256 insert_repo(&conn, repo_id, channel_id, "widget", false).await;
257 insert_channel_member(
258 &conn,
259 channel_id,
260 alice,
261 channel_members::ChannelMemberRole::Owner,
262 )
263 .await;
264
265 // Generate client ed25519 keypair and register the public key.
266 let mut rng = rand::rng();
267 let client_key = russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
268 .expect("client key");
269 let client_pub = client_key
270 .public_key()
271 .to_openssh()
272 .expect("client pub openssh");
273 insert_ssh_key(&conn, alice, &client_pub).await;
274
275 // Write client private key for ssh.
276 let client_key_path = root.join("id_ed25519");
277 let client_key_pem = client_key
278 .to_openssh(ssh_key::LineEnding::LF)
279 .expect("client key pem");
280 std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key");
281 std::fs::set_permissions(&client_key_path, std::fs::Permissions::from_mode(0o600))
282 .expect("chmod client key");
283
284 let bare = git_root.join(format!("{}/{}.git", channel_id, repo_id));
285 std::fs::create_dir_all(bare.parent().expect("bare parent")).expect("bare dirs");
286 git(&root, &["init", "--bare", bare.to_str().expect("bare str")]).await;
287
288 // Seed the bare repo with an initial commit via a throwaway working tree.
289 let seed_dir = root.join("seed");
290 std::fs::create_dir_all(&seed_dir).expect("seed dir");
291 git(
292 &root,
293 &[
294 "init",
295 "--initial-branch=main",
296 seed_dir.to_str().expect("seed str"),
297 ],
298 )
299 .await;
300 std::fs::write(seed_dir.join("README.md"), "seed\n").expect("write seed");
301 git(&seed_dir, &["add", "README.md"]).await;
302 git(
303 &seed_dir,
304 &[
305 "-c",
306 "user.name=Seed",
307 "-c",
308 "user.email=seed@example.com",
309 "commit",
310 "-m",
311 "initial",
312 ],
313 )
314 .await;
315 git(
316 &seed_dir,
317 &["remote", "add", "origin", bare.to_str().expect("bare str")],
318 )
319 .await;
320 git(&seed_dir, &["push", "-u", "origin", "main"]).await;
321
322 // Host key for the server.
323 let host_key = generate_host_key().expect("host key");
324 let host_key_path = root.join("host_key");
325 let host_key_pem = host_key
326 .to_openssh(ssh_key::LineEnding::LF)
327 .expect("host key pem");
328 std::fs::write(&host_key_path, host_key_pem.as_bytes()).expect("write host key");
329
330 let jwt = JwtState::new(
331 conn.clone(),
332 JwtSettings::default(),
333 Arc::new(RwLock::new(SigningKeyCache::new())),
334 );
335 let auth = GitAuthService::new(db, None, jwt);
336 let config = GitSshConfig {
337 git_storage_root: git_root.to_str().expect("git root str").to_string(),
338 host_key_path: host_key_path.to_str().expect("host key str").to_string(),
339 ..Default::default()
340 };
341 let svc = GitSshService::new(auth, config);
342
343 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
344 .await
345 .expect("bind");
346 let addr = listener.local_addr().expect("addr");
347 let port = addr.port();
348 tokio::spawn(async move {
349 let _ = serve_on_listener(svc, listener).await;
350 });
351 tokio::time::sleep(Duration::from_millis(100)).await;
352
353 let ssh_env = format!(
354 "ssh -i {} -p {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes",
355 client_key_path.display(),
356 port
357 );
358
359 TestServer {
360 _tmp: tmp,
361 addr,
362 port,
363 git_root,
364 channel_id,
365 repo_id,
366 ssh_env,
367 }
368}
369
370async fn git_ssh(dir: &Path, ssh_env: &str, args: &[&str]) -> String {
371 let out = tokio::process::Command::new("git")
372 .args(args)
373 .current_dir(dir)
374 .env("GIT_TERMINAL_PROMPT", "0")
375 .env("GIT_SSH_COMMAND", ssh_env)
376 .output()
377 .await
378 .expect("run git");
379 assert!(
380 out.status.success(),
381 "git {:?} failed: {}",
382 args,
383 String::from_utf8_lossy(&out.stderr)
384 );
385 String::from_utf8_lossy(&out.stdout).into_owned()
386}
387
388#[tokio::test]
389async fn ssh_clone_and_push_roundtrip() {
390 let server = start_server().await;
391 let url = format!("ssh://alice@127.0.0.1:{}/acme/widget.git", server.port);
392
393 // Clone (upload-pack).
394 let clone_dir = server._tmp.path().join("clone");
395 git_ssh(
396 server._tmp.path(),
397 &server.ssh_env,
398 &["clone", &url, clone_dir.to_str().expect("clone str")],
399 )
400 .await;
401
402 // Make a commit and push (receive-pack).
403 std::fs::write(clone_dir.join("hello.txt"), "hello ssh\n").expect("write file");
404 git(&clone_dir, &["add", "hello.txt"]).await;
405 git(
406 &clone_dir,
407 &[
408 "-c",
409 "user.name=Alice",
410 "-c",
411 "user.email=alice@example.com",
412 "commit",
413 "-m",
414 "add hello",
415 ],
416 )
417 .await;
418 git_ssh(
419 &clone_dir,
420 &server.ssh_env,
421 &["push", "origin", "HEAD:main"],
422 )
423 .await;
424
425 // Verify the object landed in the bare repo.
426 let bare = server
427 .git_root
428 .join(format!("{}/{}.git", server.channel_id, server.repo_id));
429 let out = tokio::process::Command::new("git")
430 .args(["log", "--oneline", "-1"])
431 .current_dir(&bare)
432 .output()
433 .await
434 .expect("git log");
435 assert!(
436 out.status.success(),
437 "git log failed: {}",
438 String::from_utf8_lossy(&out.stderr)
439 );
440 let log = String::from_utf8_lossy(&out.stdout);
441 assert!(
442 log.contains("add hello"),
443 "expected commit in bare repo, got: {log}"
444 );
445}
Added lib/gitssh/config.rs View lib/gitssh/config.rs
@@ -0,0 +1,32 @@
1use std::time::Duration;
2
3#[derive(Debug, Clone)]
4pub struct GitSshConfig {
5 pub bind_addr: String,
6 pub port: u16,
7 pub host_key_path: String,
8 pub git_storage_root: String,
9 pub max_sessions: usize,
10 pub banner: Option<String>,
11 pub auth_rejection_time: Duration,
12 pub inactivity_timeout: Option<Duration>,
13 pub keepalive_interval: Duration,
14 pub keepalive_max: u32,
15}
16
17impl Default for GitSshConfig {
18 fn default() -> Self {
19 Self {
20 bind_addr: "0.0.0.0".to_string(),
21 port: 2222,
22 host_key_path: "/etc/gitssh/ssh_host_ed25519_key".to_string(),
23 git_storage_root: "/var/lib/repo/git".to_string(),
24 max_sessions: 32,
25 banner: None,
26 auth_rejection_time: Duration::from_secs(1),
27 inactivity_timeout: Some(Duration::from_secs(300)),
28 keepalive_interval: Duration::from_secs(30),
29 keepalive_max: 3,
30 }
31 }
32}
Added lib/gitssh/error.rs View lib/gitssh/error.rs
@@ -0,0 +1,41 @@
1use std::io;
2
3#[derive(Debug, thiserror::Error)]
4pub enum SshError {
5 #[error("authentication rejected: {0}")]
6 AuthRejected(String),
7 #[error("failed to load host key: {0}")]
8 KeyLoadFailed(String),
9 #[error("repository not found: {0}")]
10 RepoNotFound(String),
11 #[error("permission denied: {0}")]
12 PermissionDenied(String),
13 #[error("invalid command: {0}")]
14 InvalidCommand(String),
15 #[error("io error: {0}")]
16 Io(#[from] io::Error),
17 #[error("db error: {0}")]
18 Db(#[from] db::conn::SqlxError),
19 #[error("ssh error: {0}")]
20 Ssh(#[from] russh::Error),
21 #[error("internal error: {0}")]
22 Internal(String),
23}
24
25pub type SshResult<T> = Result<T, SshError>;
26
27impl From<gitsmart::SmartError> for SshError {
28 fn from(err: gitsmart::SmartError) -> Self {
29 match err {
30 gitsmart::SmartError::Unauthorized(m)
31 | gitsmart::SmartError::BadRequest(m)
32 | gitsmart::SmartError::Conflict(m) => Self::AuthRejected(m),
33 gitsmart::SmartError::Forbidden(m) => Self::PermissionDenied(m),
34 gitsmart::SmartError::NotFound(m) => Self::RepoNotFound(m),
35 gitsmart::SmartError::Internal(m) => Self::Internal(m),
36 gitsmart::SmartError::Io(e) => Self::Io(e),
37 gitsmart::SmartError::Redis(e) => Self::Internal(format!("redis error: {e}")),
38 other => Self::Internal(other.to_string()),
39 }
40 }
41}
Added lib/gitssh/git_service.rs View lib/gitssh/git_service.rs
@@ -0,0 +1,189 @@
1use std::path::PathBuf;
2
3pub const MAX_REPO_PATH_LEN: usize = 512;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum GitService {
7 UploadPack,
8 ReceivePack,
9 UploadArchive,
10}
11
12impl GitService {
13 pub fn as_subcommand(&self) -> &'static str {
14 match self {
15 Self::UploadPack => "upload-pack",
16 Self::ReceivePack => "receive-pack",
17 Self::UploadArchive => "upload-archive",
18 }
19 }
20
21 pub fn is_write(&self) -> bool {
22 matches!(self, Self::ReceivePack)
23 }
24}
25
26impl std::str::FromStr for GitService {
27 type Err = ();
28
29 fn from_str(s: &str) -> Result<Self, Self::Err> {
30 match s {
31 "upload-pack" => Ok(Self::UploadPack),
32 "receive-pack" => Ok(Self::ReceivePack),
33 "upload-archive" => Ok(Self::UploadArchive),
34 _ => Err(()),
35 }
36 }
37}
38
39/// Parse a git-over-ssh exec command like `git-upload-pack 'acme/widget.git'`.
40/// Returns the service and the raw (single-quoted) path.
41pub fn parse_git_command(cmd: &str) -> Option<(GitService, &str)> {
42 let (bin, path) = cmd.split_once(' ')?;
43 let service = match bin {
44 "git-upload-pack" => GitService::UploadPack,
45 "git-receive-pack" => GitService::ReceivePack,
46 "git-upload-archive" => GitService::UploadArchive,
47 _ => return None,
48 };
49 Some((service, path))
50}
51
52fn strip_apostrophes(s: &str) -> &str {
53 s.trim().trim_matches('\'')
54}
55
56/// Parse a repository path into (owner, repo). Both segments must be non-empty.
57/// Enforces a strict character gate to block path traversal and shell metacharacters.
58pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
59 let path = strip_apostrophes(path).trim_matches('/');
60 if path.is_empty() || path.len() > MAX_REPO_PATH_LEN {
61 return None;
62 }
63 for b in path.bytes() {
64 let ok =
65 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_' | b'/');
66 if !ok {
67 return None;
68 }
69 }
70 let path = path.trim_end_matches(".git").trim_end_matches('/');
71 if path.is_empty() {
72 return None;
73 }
74 let (owner, repo) = path.split_once('/')?;
75 if owner.is_empty() || repo.is_empty() {
76 return None;
77 }
78 if owner == "." || owner == ".." || repo.starts_with("..") || repo.contains("/../") {
79 return None;
80 }
81 Some((owner, repo))
82}
83
84/// Sanitize untrusted input for safe logging.
85pub fn sanitize_for_log(input: &str) -> String {
86 let mut out = String::with_capacity(input.len().min(256));
87 for &b in input.as_bytes().iter().take(256) {
88 match b {
89 0x00..=0x1f | 0x7f => out.push('?'),
90 0x80..=0x9f => out.push('?'),
91 _ => out.push(b as char),
92 }
93 }
94 out
95}
96
97/// Build the git subprocess command for a bare repository dir.
98pub fn build_git_command(service: GitService, bare: &PathBuf) -> tokio::process::Command {
99 let mut cmd = tokio::process::Command::new("git");
100 cmd.current_dir(bare)
101 .arg(service.as_subcommand())
102 .arg(".")
103 .env("GIT_CONFIG_NOSYSTEM", "1")
104 .env("GIT_NO_REPLACE_OBJECTS", "1");
105 #[cfg(unix)]
106 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
107 .env("GIT_CONFIG_SYSTEM", "/dev/null");
108 cmd
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn parses_valid_commands() {
117 assert_eq!(
118 parse_git_command("git-upload-pack 'acme/widget.git'"),
119 Some((GitService::UploadPack, "'acme/widget.git'"))
120 );
121 assert_eq!(
122 parse_git_command("git-receive-pack 'acme/widget.git'"),
123 Some((GitService::ReceivePack, "'acme/widget.git'"))
124 );
125 assert_eq!(
126 parse_git_command("git-upload-archive 'acme/widget.git'"),
127 Some((GitService::UploadArchive, "'acme/widget.git'"))
128 );
129 }
130
131 #[test]
132 fn rejects_invalid_commands() {
133 assert_eq!(parse_git_command(""), None);
134 assert_eq!(parse_git_command("ls -la"), None);
135 assert_eq!(parse_git_command("git-upload-pack"), None);
136 assert_eq!(parse_git_command("git-upload-packx 'a/b.git'"), None);
137 assert_eq!(parse_git_command("rm -rf /"), None);
138 assert_eq!(parse_git_command("git-upload-pack; rm -rf /"), None);
139 assert_eq!(
140 parse_git_command("GIT_SSH=1 git-upload-pack 'a/b.git'"),
141 None
142 );
143 }
144
145 #[test]
146 fn parses_valid_paths() {
147 assert_eq!(parse_repo_path("acme/widget.git"), Some(("acme", "widget")));
148 assert_eq!(parse_repo_path("acme/widget"), Some(("acme", "widget")));
149 assert_eq!(
150 parse_repo_path("'acme/widget.git'"),
151 Some(("acme", "widget"))
152 );
153 assert_eq!(
154 parse_repo_path("/acme/widget.git/"),
155 Some(("acme", "widget"))
156 );
157 assert_eq!(parse_repo_path("a-b/c_d.git"), Some(("a-b", "c_d")));
158 }
159
160 #[test]
161 fn rejects_invalid_paths() {
162 assert_eq!(parse_repo_path(""), None);
163 assert_eq!(parse_repo_path("."), None);
164 assert_eq!(parse_repo_path(".."), None);
165 assert_eq!(parse_repo_path("../etc/passwd"), None);
166 assert_eq!(parse_repo_path("a/.."), None);
167 assert_eq!(parse_repo_path("a/b/c"), Some(("a", "b/c")));
168 assert_eq!(parse_repo_path("Acme/Widget"), None);
169 assert_eq!(parse_repo_path("a b/c"), None);
170 assert_eq!(parse_repo_path("a/b; rm -rf"), None);
171 assert_eq!(parse_repo_path("a$(id)/c"), None);
172 assert_eq!(parse_repo_path("a/"), None);
173 assert_eq!(parse_repo_path("/a"), None);
174 assert_eq!(parse_repo_path("a/../c"), None);
175 assert_eq!(parse_repo_path("a\u{0}/c"), None);
176 assert_eq!(
177 parse_repo_path(&format!("a/{}", "b".repeat(MAX_REPO_PATH_LEN))),
178 None
179 );
180 }
181
182 #[test]
183 fn sanitizes_log_input() {
184 assert_eq!(sanitize_for_log("hello"), "hello");
185 assert_eq!(sanitize_for_log("a\u{0}b"), "a?b");
186 assert_eq!(sanitize_for_log("a\nb"), "a?b");
187 assert_eq!(sanitize_for_log(&"x".repeat(1000)).len(), 256);
188 }
189}
Added lib/gitssh/server.rs View lib/gitssh/server.rs
@@ -0,0 +1,105 @@
1use std::net::SocketAddr;
2use std::str::FromStr;
3use std::sync::Arc;
4
5use russh::keys::{Algorithm, PrivateKey};
6use russh::server::Server as _;
7use russh::{MethodKind, MethodSet, SshId, server};
8use tokio::sync::Semaphore;
9
10use crate::config::GitSshConfig;
11use crate::error::{SshError, SshResult};
12use crate::handler::GitSshHandler;
13use crate::service::GitSshService;
14
15pub struct GitSshServer {
16 service: Arc<GitSshService>,
17 session_slots: Arc<Semaphore>,
18}
19
20impl GitSshServer {
21 pub fn new(service: Arc<GitSshService>) -> Self {
22 let max = service.config.max_sessions.max(1);
23 Self {
24 service,
25 session_slots: Arc::new(Semaphore::new(max)),
26 }
27 }
28}
29
30impl server::Server for GitSshServer {
31 type Handler = GitSshHandler;
32
33 fn new_client(&mut self, _peer_addr: Option<SocketAddr>) -> Self::Handler {
34 let permit = self.session_slots.clone().try_acquire_owned().ok();
35 GitSshHandler::new(self.service.clone(), _peer_addr, permit)
36 }
37
38 fn handle_session_error(&mut self, error: russh::Error) {
39 tracing::debug!("ssh session error: {error}");
40 }
41}
42
43pub fn load_host_key(path: &str) -> SshResult<PrivateKey> {
44 let data = std::fs::read_to_string(path)
45 .map_err(|e| SshError::KeyLoadFailed(format!("cannot read {}: {e}", path)))?;
46 if let Ok(key) = PrivateKey::from_openssh(&data) {
47 return Ok(key);
48 }
49 if let Ok(key) = PrivateKey::from_str(&data) {
50 return Ok(key);
51 }
52 Err(SshError::KeyLoadFailed(format!(
53 "unsupported host key format in {}",
54 path
55 )))
56}
57
58pub fn generate_host_key() -> SshResult<PrivateKey> {
59 let mut rng = rand::rng();
60 PrivateKey::random(&mut rng, Algorithm::Ed25519)
61 .map_err(|e| SshError::KeyLoadFailed(format!("failed to generate host key: {e}")))
62}
63
64pub fn build_server_config(config: &GitSshConfig) -> SshResult<server::Config> {
65 let host_key = load_host_key(&config.host_key_path)?;
66 let mut methods = MethodSet::empty();
67 methods.push(MethodKind::PublicKey);
68 Ok(server::Config {
69 keys: vec![host_key],
70 server_id: SshId::Standard(format!("SSH-2.0-gitssh-{}", env!("CARGO_PKG_VERSION")).into()),
71 methods,
72 max_auth_attempts: 10,
73 auth_rejection_time: config.auth_rejection_time,
74 inactivity_timeout: config.inactivity_timeout,
75 keepalive_interval: Some(config.keepalive_interval),
76 keepalive_max: config.keepalive_max as usize,
77 ..Default::default()
78 })
79}
80
81/// Run the SSH server on the configured address. Never returns normally.
82pub async fn run_ssh(service: GitSshService) -> SshResult<()> {
83 let bind_addr = service.config.bind_addr.clone();
84 let port = service.config.port;
85 let config = Arc::new(build_server_config(&service.config)?);
86 let mut server = GitSshServer::new(Arc::new(service));
87 server
88 .run_on_address(config, format!("{bind_addr}:{port}"))
89 .await
90 .map_err(SshError::Io)?;
91 Ok(())
92}
93
94/// Run the SSH server on an already-bound listener. The future owns the
95/// listener and server, so it can be spawned directly. Never returns normally.
96pub async fn serve_on_listener(
97 service: GitSshService,
98 listener: tokio::net::TcpListener,
99) -> SshResult<()> {
100 let config = Arc::new(build_server_config(&service.config)?);
101 let mut server = GitSshServer::new(Arc::new(service));
102 let running = server.run_on_socket(config, &listener);
103 running.await.map_err(SshError::Io)?;
104 Ok(())
105}
Added lib/gitssh/service.rs View lib/gitssh/service.rs
@@ -0,0 +1,50 @@
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use cache::RedisCache;
5use gitauth::{GitAuthService, ResolvedRepo};
6use gitsmart::push::{NoopPushBackend, PushCoordinator, RedisPushBackend};
7
8use crate::config::GitSshConfig;
9
10#[derive(Clone)]
11pub struct GitSshService {
12 pub auth: GitAuthService,
13 pub config: GitSshConfig,
14 pub kv: Option<Arc<RedisCache>>,
15}
16
17impl GitSshService {
18 pub fn new(auth: GitAuthService, config: GitSshConfig) -> Self {
19 Self {
20 auth,
21 config,
22 kv: None,
23 }
24 }
25
26 pub fn with_kv(mut self, kv: Option<Arc<RedisCache>>) -> Self {
27 self.kv = kv;
28 self
29 }
30
31 pub fn push_coordinator(&self) -> PushCoordinator {
32 match &self.kv {
33 Some(kv) => {
34 let backend: Arc<dyn gitsmart::push::PushOperations> =
35 Arc::new(RedisPushBackend { kv: kv.clone() });
36 PushCoordinator::new(backend)
37 }
38 None => {
39 let backend: Arc<dyn gitsmart::push::PushOperations> = Arc::new(NoopPushBackend);
40 PushCoordinator::new(backend)
41 }
42 }
43 }
44
45 pub fn bare_path(&self, resolved: &ResolvedRepo) -> PathBuf {
46 PathBuf::from(&self.config.git_storage_root)
47 .join(resolved.channel.id.to_string())
48 .join(format!("{}.git", resolved.repo.id))
49 }
50}