Skip to content
Clone

feat(lib/git): add SSH transport (server, git_service, handler/auth/channel/command/git_op)

ZhenYi committed
parent 1e87781 commit 7b02b53 Browse files

10 files changed

+1207 −0

File tree 10
Added lib/git/src/ssh/handler/auth.rs View lib/git/src/ssh/handler/auth.rs
@@ -0,0 +1,123 @@
1use std::str::FromStr;
2
3use models::users::user;
4use models::users::user_ssh_keys;
5use russh::keys::PublicKey;
6use russh::server::Auth;
7use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
8use time::OffsetDateTime;
9use tokio::task;
10use uuid::Uuid;
11
12use crate::ssh::handler::GitSshHandler;
13
14struct SshKeyUser {
15 user: user::Model,
16 key_id: Uuid,
17}
18
19impl GitSshHandler {
20 async fn find_user_by_public_key(
21 &self,
22 username: &str,
23 public_key: &PublicKey,
24 ) -> Result<Option<SshKeyUser>, sea_orm::DbErr> {
25 let db = self.service.smart.git.db_read();
26 let Some(user) = user::Entity::find()
27 .filter(user::Column::LowerName.eq(username.to_lowercase()))
28 .one(db)
29 .await?
30 else {
31 return Ok(None);
32 };
33 if !user.is_active {
34 return Ok(None);
35 }
36 let keys = user_ssh_keys::Entity::find()
37 .filter(user_ssh_keys::Column::UserId.eq(user.uid))
38 .all(db)
39 .await?;
40 let presented = public_key.to_string();
41 for key in keys {
42 let matched = PublicKey::from_str(key.content.trim())
43 .ok()
44 .map_or_else(
45 || key.content.trim() == presented.trim(),
46 |p| p == *public_key,
47 );
48 if matched {
49 return Ok(Some(SshKeyUser {
50 user,
51 key_id: key.id,
52 }));
53 }
54 }
55 Ok(None)
56 }
57
58 fn update_key_last_used(&self, key_id: Uuid) {
59 let db = self.service.smart.git.state.database.clone();
60 let now = OffsetDateTime::now_utc();
61 task::spawn(async move {
62 let _ = user_ssh_keys::Entity::update_many()
63 .set(user_ssh_keys::ActiveModel {
64 id: Set(key_id),
65 last_used_at: Set(Some(now)),
66 ..Default::default()
67 })
68 .filter(user_ssh_keys::Column::Id.eq(key_id))
69 .exec(db.write())
70 .await;
71 });
72 }
73
74 pub async fn auth_publickey_offered_impl(
75 &mut self,
76 user: &str,
77 public_key: &PublicKey,
78 ) -> Result<Auth, russh::Error> {
79 if self.over_capacity {
80 return Ok(Auth::reject());
81 }
82 if user.is_empty() || public_key.to_string().len() < 32 {
83 return Ok(Auth::reject());
84 }
85 match self.find_user_by_public_key(user, public_key).await {
86 Ok(Some(_)) => Ok(Auth::Accept),
87 Ok(None) => Ok(Auth::reject()),
88 Err(e) => {
89 tracing::warn!("ssh auth lookup failed: {e}");
90 Err(russh::Error::NotAuthenticated)
91 }
92 }
93 }
94
95 pub async fn auth_publickey_impl(
96 &mut self,
97 user: &str,
98 public_key: &PublicKey,
99 ) -> Result<Auth, russh::Error> {
100 if self.over_capacity {
101 return Ok(Auth::reject());
102 }
103 if user.is_empty() || public_key.to_string().len() < 32 {
104 return Ok(Auth::reject());
105 }
106 match self.find_user_by_public_key(user, public_key).await {
107 Ok(Some(found)) => {
108 if found.user.is_restricted || found.user.prohibit_login {
109 return Ok(Auth::reject());
110 }
111 let key_id = found.key_id;
112 self.operator = Some(found.user);
113 self.update_key_last_used(key_id);
114 Ok(Auth::Accept)
115 }
116 Ok(None) => Ok(Auth::reject()),
117 Err(e) => {
118 tracing::warn!("ssh auth lookup failed: {e}");
119 Err(russh::Error::NotAuthenticated)
120 }
121 }
122 }
123}
No newline at end of file
Added lib/git/src/ssh/handler/channel.rs View lib/git/src/ssh/handler/channel.rs
@@ -0,0 +1,336 @@
1use std::sync::Arc;
2
3use bytes::Bytes;
4use cache::CacheClient;
5use russh::ChannelId;
6use russh::server::Session;
7use sea_orm::EntityTrait;
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9
10use crate::context::facade::GitContext;
11use crate::smart::error::SmartError;
12use crate::smart::pre_receive::parse_receive_pack_refs;
13use crate::ssh::error::SshError;
14use crate::ssh::git_service::apply_hardened_env;
15use crate::ssh::handler::GitSshHandler;
16use crate::sync::post_receive::enqueue_post_receive_push;
17
18pub const MAX_SSH_CHANNEL_BUF: usize = 64 * 1024 * 1024;
19pub const MAX_GIT_OUTPUT_BUF: usize = 256 * 1024 * 1024;
20
21impl GitSshHandler {
22 pub async fn data_impl(
23 &mut self,
24 channel: ChannelId,
25 data: &[u8],
26 session: &mut Session,
27 ) -> Result<(), russh::Error> {
28 let state = self.channels.entry(channel).or_default();
29 if state.buffer_overflow {
30 return Ok(());
31 }
32 let projected = state.buffer.len().saturating_add(data.len());
33 if projected > MAX_SSH_CHANNEL_BUF {
34 tracing::warn!(
35 channel = ?channel,
36 buffered = state.buffer.len(),
37 incoming = data.len(),
38 limit = MAX_SSH_CHANNEL_BUF,
39 "ssh channel buffer overflow; closing channel"
40 );
41 state.buffer_overflow = true;
42 state.buffer.clear();
43 let _ = session.close(channel);
44 let _ = session.flush();
45 return Ok(());
46 }
47 state.buffer.extend_from_slice(data);
48 Ok(())
49 }
50
51 pub async fn channel_close_impl(&mut self, channel: ChannelId) {
52 self.channels.remove(&channel);
53 }
54
55 pub async fn channel_eof_impl(
56 &mut self,
57 channel: ChannelId,
58 session: &mut Session,
59 ) -> Result<(), russh::Error> {
60 let Some(mut state) = self.channels.remove(&channel) else {
61 let _ = session.close(channel);
62 let _ = session.flush();
63 return Ok(());
64 };
65 let overflowed = state.buffer_overflow;
66 let op = state.git_op.take();
67 let buffer = std::mem::take(&mut state.buffer);
68 let repo_id = state.repo_id;
69 let space_id = state.space_id;
70 let actor_id = state.actor_id;
71 let push_coordinator = self.service.push_coordinator();
72 let queue = self.service.smart.queue.clone();
73 let sync_settings = self.service.sync_settings.clone();
74 let ssh_service = self.service.clone();
75
76 let handle = session.handle();
77 let db = self.service.smart.git.state.database.clone();
78 tokio::spawn(async move {
79 if overflowed {
80 _ = handle.exit_status_request(channel, 1);
81 _ = handle.eof(channel);
82 _ = handle.close(channel);
83 let _ = handle
84 .data(
85 channel,
86 Bytes::from_static(
87 b"remote: request rejected: ssh channel buffer cap exceeded\n",
88 ),
89 )
90 .await;
91 return;
92 }
93 let is_write = op.as_ref().is_some_and(|o| o.is_write());
94 let is_receive = matches!(
95 op.as_ref(),
96 Some(crate::ssh::handler::git_op::GitOperation::ReceivePack { .. })
97 );
98
99 if is_receive && let (Some(repo_id), Some(actor_uid)) = (repo_id, actor_id) {
100 match parse_receive_pack_refs(&buffer) {
101 Ok(updates) if !updates.is_empty() => {
102 let baby = match build_baby_for_pre_receive(&ssh_service, repo_id).await {
103 Some(b) => b,
104 None => {
105 tracing::error!(
106 repo_id = %repo_id,
107 "cannot build baby for pre-receive; refusing push"
108 );
109 let _ = handle
110 .data(
111 channel,
112 Bytes::from_static(
113 b"remote: internal error during pre-receive setup\n",
114 ),
115 )
116 .await;
117 _ = handle.exit_status_request(channel, 1);
118 _ = handle.eof(channel);
119 _ = handle.close(channel);
120 return;
121 }
122 };
123 let verdict = match crate::smart::pre_receive::validate_pre_receive(
124 db.read(),
125 &baby,
126 repo_id,
127 actor_uid,
128 &updates,
129 )
130 .await
131 {
132 Ok(v) => v,
133 Err(e) => {
134 tracing::error!(
135 ?e, repo_id = %repo_id,
136 "pre-receive validation failed; refusing push"
137 );
138 let _ = handle
139 .data(
140 channel,
141 Bytes::from_static(
142 b"remote: internal error during pre-receive validation\n",
143 ),
144 )
145 .await;
146 _ = handle.exit_status_request(channel, 1);
147 _ = handle.eof(channel);
148 _ = handle.close(channel);
149 return;
150 }
151 };
152 if let Err(e) = verdict.accepted_only() {
153 let msg = format!("remote: {e}\n");
154 _ = handle.data(channel, Bytes::from(msg.into_bytes())).await;
155 _ = handle.exit_status_request(channel, 1);
156 _ = handle.eof(channel);
157 _ = handle.close(channel);
158 return;
159 }
160 }
161 Ok(_) => {}
162 Err(e) => {
163 tracing::error!(
164 ?e, repo_id = %repo_id,
165 "receive-pack ref parse failed; refusing push"
166 );
167 let _ = handle
168 .data(
169 channel,
170 Bytes::from_static(
171 b"remote: invalid receive-pack ref update\n",
172 ),
173 )
174 .await;
175 _ = handle.exit_status_request(channel, 1);
176 _ = handle.eof(channel);
177 _ = handle.close(channel);
178 return;
179 }
180 }
181 }
182
183 if is_write && let Some(bare) = op.as_ref().map(|o| o.bare().clone()) {
184 if let Err(e) = crate::sync::post_receive::prepare_post_receive(&bare) {
185 tracing::warn!(?e, "post-receive hook setup failed");
186 }
187 let push_key = format!("git_op:{}", bare.display());
188
189 let run = async {
190 run_git(op.as_ref(), &buffer, &handle, channel).await
191 };
192 let result = push_coordinator
193 .run(&push_key, async {
194 run.await.map_err(|e| SmartError::Internal(e.to_string()))
195 })
196 .await
197 .map_err(|e: SmartError| SshError::from(e));
198 if result.is_ok()
199 && let (Some(repo_id), Some(space_id), Some(queue)) = (repo_id, space_id, &queue)
200 {
201 match enqueue_post_receive_push(
202 queue,
203 &sync_settings,
204 space_id,
205 repo_id,
206 actor_id,
207 &bare,
208 )
209 .await
210 {
211 Ok(n) if n > 0 => {
212 tracing::debug!(count = n, "enqueued post-receive ref updates")
213 }
214 Ok(_) => tracing::debug!("no refs to enqueue"),
215 Err(e) => tracing::warn!(?e, "post-receive enqueue failed"),
216 }
217 }
218 let code = if result.is_ok() { 0 } else { 1 };
219 _ = handle.exit_status_request(channel, code);
220 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
221 _ = handle.eof(channel);
222 _ = handle.close(channel);
223 } else {
224 let result = run_git(op.as_ref(), &buffer, &handle, channel).await;
225 let code = if result.is_ok() { 0 } else { 1 };
226 _ = handle.exit_status_request(channel, code);
227 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
228 _ = handle.eof(channel);
229 _ = handle.close(channel);
230 }
231 });
232
233 Ok(())
234 }
235}
236
237async fn run_git(
238 op: Option<&crate::ssh::handler::git_op::GitOperation>,
239 input: &[u8],
240 handle: &russh::server::Handle,
241 channel: russh::ChannelId,
242) -> Result<(), SshError> {
243 let Some(op) = op else {
244 return Ok(());
245 };
246 let result: Result<(), SshError> = async {
247 let mut cmd = tokio::process::Command::new("git");
248 cmd.current_dir(op.bare()).arg(op.service_arg());
249 if !op.is_upload_archive() {
250 cmd.arg("--stateless-rpc");
251 }
252 cmd.env("GIT_DIR", op.bare())
253 .env("GIT_TERMINAL_PROMPT", "0")
254 .stdin(std::process::Stdio::piped())
255 .stdout(std::process::Stdio::piped())
256 .stderr(std::process::Stdio::piped());
257 apply_hardened_env(&mut cmd);
258
259 let mut child = cmd.spawn().map_err(SshError::Io)?;
260 if let Some(mut stdin) = child.stdin.take() {
261 stdin
262 .write_all(input)
263 .await
264 .map_err(SshError::Io)?;
265 let _ = stdin.shutdown().await;
266 }
267 let stdout_cap = MAX_GIT_OUTPUT_BUF as u64 + 1;
268 let stdout = if let Some(out) = child.stdout.take() {
269 let mut buf = Vec::new();
270 out.take(stdout_cap).read_to_end(&mut buf).await.map_err(SshError::Io)?;
271 buf
272 } else {
273 Vec::new()
274 };
275 let stderr = if let Some(err) = child.stderr.take() {
276 let mut buf = Vec::new();
277 err.take(stdout_cap).read_to_end(&mut buf).await.map_err(SshError::Io)?;
278 buf
279 } else {
280 Vec::new()
281 };
282 let status = child.wait().await.map_err(SshError::Io)?;
283 if stdout.len() > MAX_GIT_OUTPUT_BUF || stderr.len() > MAX_GIT_OUTPUT_BUF {
284 return Err(SshError::Internal(format!(
285 "git {} output exceeded {} bytes",
286 op.service_arg(),
287 MAX_GIT_OUTPUT_BUF
288 )));
289 }
290 if !stdout.is_empty() {
291 _ = handle.data(channel, Bytes::from(stdout)).await;
292 }
293 if !stderr.is_empty() && !status.success() {
294 _ = handle.extended_data(channel, 1, Bytes::from(stderr)).await;
295 }
296 if status.success() {
297 Ok(())
298 } else {
299 Err(SshError::Internal(format!(
300 "git {} failed: exit {:?}",
301 op.service_arg(),
302 status.code()
303 )))
304 }
305 }
306 .await;
307 result
308}
309
310/// Build a GitBaby for `validate_pre_receive`. The SSH path does not have
311/// `(space, repo)` strings available — only `repo_id` — so we resolve the
312/// repo model directly and assemble a `GitContext` from `AppState`. This
313/// mirrors `service::context::context` minus the space-name lookup, which
314/// is unnecessary here because `validate_pre_receive` only needs the bare
315/// repo metadata + a configured repo_root/objects_root.
316async fn build_baby_for_pre_receive(
317 service: &Arc<crate::ssh::service::GitSshService>,
318 repo_id: uuid::Uuid,
319) -> Option<gitbaby::GitBaby> {
320 use models::repo::repos;
321 let db = service.smart.git.state.database.clone();
322 let repo = repos::Entity::find_by_id(repo_id).one(db.read()).await.ok()??;
323 let cache = match CacheClient::new(&service.smart.git.state.config.cache).await {
324 Ok(c) => Arc::new(c),
325 Err(_) => return None,
326 };
327 let git_cfg = &service.smart.git.state.config.git;
328 let ctx = GitContext {
329 model: repo,
330 cache,
331 database: db,
332 repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
333 objects_root: std::path::PathBuf::from(&git_cfg.objects_root),
334 };
335 Some(service.smart.git.baby(&ctx))
336}
Added lib/git/src/ssh/handler/command.rs View lib/git/src/ssh/handler/command.rs
@@ -0,0 +1,224 @@
1use bytes::Bytes;
2use russh::ChannelId;
3use russh::server::Session;
4use sea_orm::EntityTrait;
5use uuid::Uuid;
6
7use crate::share::access::authorize_repo_access;
8use crate::share::errors::GitError;
9use crate::smart::error::SmartError;
10use crate::ssh::error::SshError;
11use crate::ssh::git_service::{
12 GitService, build_git_advertise_command, parse_git_command, parse_repo_path, sanitize_for_log,
13};
14use crate::ssh::handler::GitSshHandler;
15use crate::ssh::handler::git_op::GitOperation;
16
17impl GitSshHandler {
18 pub async fn exec_request_impl(
19 &mut self,
20 channel: ChannelId,
21 data: &[u8],
22 session: &mut Session,
23 ) -> Result<(), russh::Error> {
24 let cmd = match std::str::from_utf8(data) {
25 Ok(c) => c,
26 Err(_) => {
27 invalid_command("invalid command encoding", session, channel).await;
28 return Ok(());
29 }
30 };
31 let cmd = cmd.trim();
32 tracing::info!(
33 "ssh exec from {:?}: {}",
34 self.client_addr,
35 sanitize_for_log(cmd)
36 );
37
38 let Some((service, raw_path)) = parse_git_command(cmd) else {
39 invalid_command("invalid git command", session, channel).await;
40 return Ok(());
41 };
42 let Some((space_name, repo_name)) = parse_repo_path(raw_path) else {
43 invalid_command("invalid repository path", session, channel).await;
44 return Ok(());
45 };
46
47 let actor_id = self.operator.as_ref().map(|u| u.uid);
48 let required = if service.is_write() {
49 "write"
50 } else {
51 "read"
52 };
53
54 if let Err(msg) = self
55 .authorize_repo(space_name, repo_name, service.is_write())
56 .await
57 {
58 tracing::warn!("ssh {service:?} {space_name}/{repo_name} denied: {msg}");
59 invalid_command(&msg, session, channel).await;
60 return Ok(());
61 }
62
63 let ctx = match self
64 .service
65 .smart
66 .resolve(space_name, repo_name)
67 .await
68 {
69 Ok(c) => c,
70 Err(e) => {
71 let msg = format!("fatal: {e}");
72 invalid_command(&msg, session, channel).await;
73 return Ok(());
74 }
75 };
76 let space_id = match self.space_id_for(&ctx.model.dir_id).await {
77 Ok(id) => id,
78 Err(_) => ctx.model.dir_id,
79 };
80
81 let bare = ctx.repo_dir();
82 if !bare.is_dir() {
83 let msg = format!(
84 "fatal: repository '{space_name}/{repo_name}' does not exist"
85 );
86 invalid_command(&msg, session, channel).await;
87 return Ok(());
88 }
89
90 let adv = match run_advertise(&bare, service).await {
91 Ok(adv) => adv,
92 Err(e) => {
93 let msg = format!("fatal: unable to read repository advertisement: {e}");
94 invalid_command(&msg, session, channel).await;
95 return Ok(());
96 }
97 };
98 if !adv.is_empty() {
99 let _ = session.data(channel, Bytes::from(adv));
100 }
101 let _ = session.channel_success(channel);
102 let _ = session.flush();
103
104 let op = match service {
105 GitService::UploadPack => GitOperation::UploadPack { bare: bare.clone() },
106 GitService::UploadArchive => GitOperation::UploadArchive { bare: bare.clone() },
107 GitService::ReceivePack => GitOperation::ReceivePack { bare: bare.clone() },
108 };
109 let state = self.channels.entry(channel).or_default();
110 state.git_op = Some(op);
111 state.repo_id = Some(ctx.model.id);
112 state.space_id = Some(space_id);
113 state.actor_id = actor_id;
114 let _ = required;
115 Ok(())
116 }
117
118 async fn authorize_repo(
119 &self,
120 space: &str,
121 repo: &str,
122 want_write: bool,
123 ) -> Result<(), String> {
124 let svc = &self.service.smart.git;
125 let ctx = svc
126 .context(space.to_string(), repo.to_string())
127 .await
128 .map_err(|e| format!("{e}"))?;
129 let Some(operator) = &self.operator else {
130 return Err("authentication required".into());
131 };
132 authorize_repo_access(svc, operator.uid, &ctx, want_write, false)
133 .await
134 .map_err(|e| match e {
135 GitError::Forbidden => "permission denied".to_string(),
136 other => format!("{other}"),
137 })
138 }
139
140 async fn space_id_for(&self, dir_id: &Uuid) -> Result<Uuid, SmartError> {
141 let svc = &self.service.smart.git;
142 let mut current = *dir_id;
143 loop {
144 let dir = models::dir::dirs::Entity::find_by_id(current)
145 .one(svc.db_read())
146 .await
147 .map_err(SmartError::Db)?;
148 let dir = dir.ok_or_else(|| SmartError::NotFound(format!("dir {current}")))?;
149 if let Some(space_id) = dir.space_id {
150 return Ok(space_id);
151 }
152 match dir.parent_dir_id {
153 Some(parent) => current = parent,
154 None => return Err(SmartError::NotFound("space".into())),
155 }
156 }
157 }
158
159 pub async fn shell_request_impl(
160 &mut self,
161 channel: ChannelId,
162 session: &mut Session,
163 ) -> Result<(), russh::Error> {
164 let _ = session.data(channel, Bytes::from_static(
165 b"Welcome to GitData SSH. This is a git-only server; shell access is disabled.\r\n",
166 ));
167 let _ = session.exit_status_request(channel, 0);
168 let _ = session.eof(channel);
169 let _ = session.close(channel);
170 let _ = session.flush();
171 Ok(())
172 }
173}
174
175async fn invalid_command(msg: &str, session: &mut Session, channel: ChannelId) {
176 let _ = session.data(channel, Bytes::from(format!("{msg}\n").into_bytes()));
177 let _ = session.channel_failure(channel);
178 let _ = session.exit_status_request(channel, 1);
179 let _ = session.eof(channel);
180 let _ = session.close(channel);
181 let _ = session.flush();
182}
183
184async fn run_advertise(
185 bare: &std::path::Path,
186 service: GitService,
187) -> Result<Vec<u8>, SshError> {
188 let mut cmd = build_git_advertise_command(service, bare);
189 cmd.stdout(std::process::Stdio::piped())
190 .stderr(std::process::Stdio::piped());
191 let mut child = cmd.spawn().map_err(SshError::Io)?;
192 let stderr_task = child.stderr.take().map(|stderr| {
193 tokio::spawn(async move {
194 use tokio::io::AsyncReadExt;
195 let mut s = stderr;
196 let mut buf = Vec::new();
197 let _ = s.read_to_end(&mut buf).await;
198 buf
199 })
200 });
201 let mut stdout = child
202 .stdout
203 .take()
204 .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
205 let mut out = Vec::new();
206 use tokio::io::AsyncReadExt;
207 stdout.read_to_end(&mut out).await.map_err(SshError::Io)?;
208 let status = child.wait().await.map_err(SshError::Io)?;
209 let stderr_bytes = match stderr_task {
210 Some(t) => t.await.unwrap_or_default(),
211 None => Vec::new(),
212 };
213 if !status.success() {
214 return Err(SshError::Internal(format!(
215 "git {} --advertise-refs failed: {}",
216 service.as_subcommand(),
217 String::from_utf8_lossy(&stderr_bytes)
218 )));
219 }
220 Ok(out)
221}
222
223#[allow(dead_code)]
224fn _keep_typed(_: GitError) {}
Added lib/git/src/ssh/handler/git_op.rs View lib/git/src/ssh/handler/git_op.rs
@@ -0,0 +1,34 @@
1use std::path::PathBuf;
2
3#[derive(Clone, Debug)]
4pub enum GitOperation {
5 UploadPack { bare: PathBuf },
6 ReceivePack { bare: PathBuf },
7 UploadArchive { bare: PathBuf },
8}
9
10impl GitOperation {
11 pub fn bare(&self) -> &PathBuf {
12 match self {
13 Self::UploadPack { bare }
14 | Self::ReceivePack { bare }
15 | Self::UploadArchive { bare } => bare,
16 }
17 }
18
19 pub fn is_write(&self) -> bool {
20 matches!(self, Self::ReceivePack { .. })
21 }
22
23 pub fn is_upload_archive(&self) -> bool {
24 matches!(self, Self::UploadArchive { .. })
25 }
26
27 pub fn service_arg(&self) -> &'static str {
28 match self {
29 Self::UploadPack { .. } => "upload-pack",
30 Self::ReceivePack { .. } => "receive-pack",
31 Self::UploadArchive { .. } => "upload-archive",
32 }
33 }
34}
No newline at end of file
Added lib/git/src/ssh/handler/mod.rs View lib/git/src/ssh/handler/mod.rs
@@ -0,0 +1,202 @@
1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::sync::Arc;
4
5use russh::server::{Auth, Handler, Msg, Session};
6use russh::{Channel, ChannelId};
7use tokio::sync::OwnedSemaphorePermit;
8use uuid::Uuid;
9
10use models::users::user;
11
12use crate::ssh::handler::git_op::GitOperation;
13use crate::ssh::service::GitSshService;
14
15pub mod git_op;
16
17mod auth;
18mod channel;
19mod command;
20
21#[derive(Default)]
22pub struct ChannelState {
23 pub buffer: Vec<u8>,
24 pub git_op: Option<GitOperation>,
25 pub repo_id: Option<Uuid>,
26 pub space_id: Option<Uuid>,
27 pub actor_id: Option<Uuid>,
28 pub buffer_overflow: bool,
29}
30
31pub struct GitSshHandler {
32 pub service: Arc<GitSshService>,
33 pub client_addr: Option<SocketAddr>,
34 pub session_permit: Option<OwnedSemaphorePermit>,
35 pub operator: Option<user::Model>,
36 pub channels: HashMap<ChannelId, ChannelState>,
37 pub over_capacity: bool,
38}
39
40impl GitSshHandler {
41 pub fn new(
42 service: Arc<GitSshService>,
43 client_addr: Option<SocketAddr>,
44 session_permit: Option<OwnedSemaphorePermit>,
45 ) -> Self {
46 let over_capacity = session_permit.is_none();
47 Self {
48 service,
49 client_addr,
50 session_permit,
51 operator: None,
52 channels: HashMap::new(),
53 over_capacity,
54 }
55 }
56
57 pub fn config(&self) -> &config::git::GitSshConfig {
58 &self.service.config
59 }
60}
61
62impl Handler for GitSshHandler {
63 type Error = russh::Error;
64
65 async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
66 Ok(Auth::reject())
67 }
68
69 async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
70 Ok(Auth::reject())
71 }
72
73 async fn auth_publickey_offered(
74 &mut self,
75 user: &str,
76 public_key: &russh::keys::PublicKey,
77 ) -> Result<Auth, Self::Error> {
78 self.auth_publickey_offered_impl(user, public_key).await
79 }
80
81 async fn auth_publickey(
82 &mut self,
83 user: &str,
84 public_key: &russh::keys::PublicKey,
85 ) -> Result<Auth, Self::Error> {
86 self.auth_publickey_impl(user, public_key).await
87 }
88
89 async fn auth_openssh_certificate(
90 &mut self,
91 _user: &str,
92 _certificate: &russh::keys::Certificate,
93 ) -> Result<Auth, Self::Error> {
94 Ok(Auth::reject())
95 }
96
97 async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> {
98 Ok(self.config().banner.clone())
99 }
100
101 async fn channel_open_session(
102 &mut self,
103 _channel: Channel<Msg>,
104 reply: russh::server::ChannelOpenHandle,
105 _session: &mut Session,
106 ) -> Result<(), Self::Error> {
107 reply.accept().await;
108 Ok(())
109 }
110
111 async fn channel_close(
112 &mut self,
113 channel: ChannelId,
114 _session: &mut Session,
115 ) -> Result<(), Self::Error> {
116 self.channel_close_impl(channel).await;
117 Ok(())
118 }
119
120 async fn channel_eof(
121 &mut self,
122 channel: ChannelId,
123 session: &mut Session,
124 ) -> Result<(), Self::Error> {
125 self.channel_eof_impl(channel, session).await
126 }
127
128 async fn data(
129 &mut self,
130 channel: ChannelId,
131 data: &[u8],
132 session: &mut Session,
133 ) -> Result<(), Self::Error> {
134 self.data_impl(channel, data, session).await
135 }
136
137 async fn exec_request(
138 &mut self,
139 channel: ChannelId,
140 data: &[u8],
141 session: &mut Session,
142 ) -> Result<(), Self::Error> {
143 self.exec_request_impl(channel, data, session).await
144 }
145
146 async fn pty_request(
147 &mut self,
148 _channel: ChannelId,
149 _term: &str,
150 _col_width: u32,
151 _row_height: u32,
152 _pix_width: u32,
153 _pix_height: u32,
154 _modes: &[(russh::Pty, u32)],
155 session: &mut Session,
156 ) -> Result<(), Self::Error> {
157 let _ = session.flush();
158 Ok(())
159 }
160
161 async fn shell_request(
162 &mut self,
163 channel: ChannelId,
164 session: &mut Session,
165 ) -> Result<(), Self::Error> {
166 self.shell_request_impl(channel, session).await
167 }
168
169 async fn subsystem_request(
170 &mut self,
171 _channel: ChannelId,
172 _name: &str,
173 session: &mut Session,
174 ) -> Result<(), Self::Error> {
175 let _ = session.flush();
176 Ok(())
177 }
178
179 async fn env_request(
180 &mut self,
181 _channel: ChannelId,
182 _variable_name: &str,
183 _variable_value: &str,
184 session: &mut Session,
185 ) -> Result<(), Self::Error> {
186 let _ = session.flush();
187 Ok(())
188 }
189
190 async fn window_change_request(
191 &mut self,
192 _channel: ChannelId,
193 _col_width: u32,
194 _row_height: u32,
195 _pix_width: u32,
196 _pix_height: u32,
197 session: &mut Session,
198 ) -> Result<(), Self::Error> {
199 let _ = session.flush();
200 Ok(())
201 }
202}
No newline at end of file
Added lib/git/src/ssh/error.rs View lib/git/src/ssh/error.rs
@@ -0,0 +1,40 @@
1#[derive(Debug, thiserror::Error)]
2pub enum SshError {
3 #[error("authentication rejected: {0}")]
4 AuthRejected(String),
5 #[error("failed to load host key: {0}")]
6 KeyLoadFailed(String),
7 #[error("repository not found: {0}")]
8 RepoNotFound(String),
9 #[error("permission denied: {0}")]
10 PermissionDenied(String),
11 #[error("invalid command: {0}")]
12 InvalidCommand(String),
13 #[error("io error: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("database error: {0}")]
16 Db(#[from] sea_orm::DbErr),
17 #[error("ssh error: {0}")]
18 Ssh(#[from] russh::Error),
19 #[error("internal error: {0}")]
20 Internal(String),
21}
22
23pub type SshResult<T> = Result<T, SshError>;
24
25impl From<crate::smart::error::SmartError> for SshError {
26 fn from(err: crate::smart::error::SmartError) -> Self {
27 use crate::smart::error::SmartError;
28 match err {
29 SmartError::Unauthorized => Self::AuthRejected("authentication required".into()),
30 SmartError::BadRequest(m) => Self::InvalidCommand(m),
31 SmartError::Conflict(m) => Self::Internal(m),
32 SmartError::Forbidden(m) => Self::PermissionDenied(m),
33 SmartError::NotFound(m) => Self::RepoNotFound(m),
34 SmartError::Internal(m) => Self::Internal(m),
35 SmartError::Io(e) => Self::Io(e),
36 SmartError::Db(e) => Self::Db(e),
37 SmartError::Redis(e) => Self::Internal(format!("redis error: {e}")),
38 }
39 }
40}
No newline at end of file
Added lib/git/src/ssh/git_service.rs View lib/git/src/ssh/git_service.rs
@@ -0,0 +1,112 @@
1use std::path::Path;
2
3use tokio::process::Command;
4
5pub const MAX_REPO_PATH_LEN: usize = 512;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitService {
9 UploadPack,
10 ReceivePack,
11 UploadArchive,
12}
13
14impl GitService {
15 pub const fn as_subcommand(self) -> &'static str {
16 match self {
17 Self::UploadPack => "upload-pack",
18 Self::ReceivePack => "receive-pack",
19 Self::UploadArchive => "upload-archive",
20 }
21 }
22
23 pub const fn is_write(self) -> bool {
24 matches!(self, Self::ReceivePack)
25 }
26}
27
28impl std::str::FromStr for GitService {
29 type Err = ();
30
31 fn from_str(s: &str) -> Result<Self, Self::Err> {
32 match s {
33 "upload-pack" => Ok(Self::UploadPack),
34 "receive-pack" => Ok(Self::ReceivePack),
35 "upload-archive" => Ok(Self::UploadArchive),
36 _ => Err(()),
37 }
38 }
39}
40
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
56pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
57 let path = strip_apostrophes(path).trim_matches('/');
58 if path.is_empty() || path.len() > MAX_REPO_PATH_LEN {
59 return None;
60 }
61 for b in path.bytes() {
62 let ok = b.is_ascii_lowercase()
63 || b.is_ascii_digit()
64 || matches!(b, b'.' | b'-' | b'_' | b'/');
65 if !ok {
66 return None;
67 }
68 }
69 let path = path.trim_end_matches(".git").trim_end_matches('/');
70 if path.is_empty() {
71 return None;
72 }
73 let (owner, repo) = path.split_once('/')?;
74 if owner.is_empty() || repo.is_empty() {
75 return None;
76 }
77 if owner == "." || owner == ".." || repo.starts_with("..") || repo.contains("/../") {
78 return None;
79 }
80 Some((owner, repo))
81}
82
83pub fn sanitize_for_log(input: &str) -> String {
84 let mut out = String::with_capacity(input.len().min(256));
85 for &b in input.as_bytes().iter().take(256) {
86 match b {
87 0x00..=0x1f | 0x7f => out.push('?'),
88 0x80..=0x9f => out.push('?'),
89 _ => out.push(b as char),
90 }
91 }
92 out
93}
94
95pub fn build_git_advertise_command(service: GitService, bare: &Path) -> Command {
96 let mut cmd = Command::new("git");
97 cmd.current_dir(bare)
98 .arg(service.as_subcommand())
99 .arg("--advertise-refs")
100 .env("GIT_DIR", bare)
101 .env("GIT_TERMINAL_PROMPT", "0");
102 apply_hardened_env(&mut cmd);
103 cmd
104}
105
106pub fn apply_hardened_env(cmd: &mut Command) {
107 cmd.env("GIT_CONFIG_NOSYSTEM", "1")
108 .env("GIT_NO_REPLACE_OBJECTS", "1");
109 #[cfg(unix)]
110 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
111 .env("GIT_CONFIG_SYSTEM", "/dev/null");
112}
No newline at end of file
Modified lib/git/src/ssh/mod.rs View lib/git/src/ssh/mod.rs
@@ -1 +1,14 @@
1pub mod error;
2pub mod git_service;
3pub mod handler;
4pub mod server;
5pub mod service;
16
7pub use error::{SshError, SshResult};
8pub use git_service::{
9 GitService, apply_hardened_env, build_git_advertise_command, parse_git_command, parse_repo_path,
10 sanitize_for_log,
11};
12pub use handler::GitSshHandler;
13pub use server::{build_server_config, load_host_key, run_ssh, serve_on_listener};
14pub use service::GitSshService;
No newline at end of file
Added lib/git/src/ssh/server.rs View lib/git/src/ssh/server.rs
@@ -0,0 +1,88 @@
1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use russh::keys::PrivateKey;
5use russh::server::Server as _;
6use russh::{MethodKind, MethodSet, SshId, server};
7use tokio::sync::Semaphore;
8
9use config::git::GitSshConfig;
10
11use crate::ssh::error::{SshError, SshResult};
12use crate::ssh::handler::GitSshHandler;
13use crate::ssh::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 {path}: {e}")))?;
46 PrivateKey::from_openssh(&data)
47 .map_err(|e| SshError::KeyLoadFailed(format!("openssh decode failed for {path}: {e}")))
48}
49
50pub fn build_server_config(config: &GitSshConfig) -> SshResult<server::Config> {
51 let host_key = load_host_key(&config.host_key_path)?;
52 let mut methods = MethodSet::empty();
53 methods.push(MethodKind::PublicKey);
54 Ok(server::Config {
55 server_id: SshId::Standard(format!("SSH-2.0-gitdata-{}", env!("CARGO_PKG_VERSION")).into()),
56 methods,
57 keys: vec![host_key],
58 max_auth_attempts: 10,
59 auth_rejection_time: config.auth_rejection_time,
60 inactivity_timeout: config.inactivity_timeout,
61 keepalive_interval: Some(config.keepalive_interval),
62 keepalive_max: config.keepalive_max as usize,
63 ..Default::default()
64 })
65}
66
67pub async fn run_ssh(service: GitSshService) -> SshResult<()> {
68 let bind_addr = service.config.bind_addr.clone();
69 let port = service.config.port;
70 let config = Arc::new(build_server_config(&service.config)?);
71 let mut server = GitSshServer::new(Arc::new(service));
72 server
73 .run_on_address(config, format!("{bind_addr}:{port}"))
74 .await
75 .map_err(SshError::Io)?;
76 Ok(())
77}
78
79pub async fn serve_on_listener(
80 service: GitSshService,
81 listener: tokio::net::TcpListener,
82) -> SshResult<()> {
83 let config = Arc::new(build_server_config(&service.config)?);
84 let mut server = GitSshServer::new(Arc::new(service));
85 let running = server.run_on_socket(config, &listener);
86 running.await.map_err(SshError::Io)?;
87 Ok(())
88}
No newline at end of file
Added lib/git/src/ssh/service.rs View lib/git/src/ssh/service.rs
@@ -0,0 +1,35 @@
1use std::sync::Arc;
2
3use crate::smart::push::{NoopPushBackend, PushCoordinator, PushOperations, RedisPushBackend};
4use crate::smart::service::SmartService;
5use config::git::{GitSshConfig, SyncSettings};
6
7#[derive(Clone)]
8pub struct GitSshService {
9 pub smart: SmartService,
10 pub config: GitSshConfig,
11 pub sync_settings: SyncSettings,
12}
13
14impl GitSshService {
15 pub fn new(smart: SmartService, config: GitSshConfig) -> Self {
16 Self {
17 smart,
18 config,
19 sync_settings: SyncSettings::default(),
20 }
21 }
22
23 pub fn with_sync_settings(mut self, settings: SyncSettings) -> Self {
24 self.sync_settings = settings;
25 self
26 }
27
28 pub fn push_coordinator(&self) -> PushCoordinator {
29 let backend: Arc<dyn PushOperations> = match &self.smart.kv {
30 Some(kv) => Arc::new(RedisPushBackend { kv: kv.clone() }),
31 None => Arc::new(NoopPushBackend),
32 };
33 PushCoordinator::new(backend)
34 }
35}
No newline at end of file