1pub mod actions;
18pub mod pack;
19pub mod pktline;
20
21use std::convert::Infallible;
22use std::net::SocketAddr;
23use std::sync::Arc;
24
25use bytes::Bytes;
26use http_body_util::{BodyExt, Full};
27use hyper::body::Incoming;
28use hyper::server::conn::http1;
29use hyper::service::service_fn;
30use hyper::{Method, Request, Response, StatusCode};
31use hyper_util::rt::TokioIo;
32use serde_json::Value;
33use tokio::sync::mpsc;
34use tracing::{debug, error};
35
36use crate::llm::action_helper::call_llm;
37use crate::llm::actions::protocol_trait::ActionResult;
38use crate::llm::ollama_client::OllamaClient;
39use crate::logging::emit::Log;
40use crate::protocol::Event;
41use crate::server::connection::ConnectionId;
42use crate::server::git::actions::{
43 GitProtocol, DEFAULT_COMMIT_TIMESTAMP, GIT_INFO_REFS_EVENT, GIT_UPLOAD_PACK_EVENT,
44};
45use crate::server::git::pack::{build_repo, hex_id, write_pack, BuiltRepo, CommitMeta, RepoFile};
46use crate::state::app_state::AppState;
47use crate::utils::WireFailure;
48
49const BASE_CAPABILITIES: &str = "no-progress agent=netget";
57
58pub const MAX_UPLOAD_PACK_BYTES: usize = 1024 * 1024;
65
66const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
73
74const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
88
89const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
95
96const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
102 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
103
104struct RequestContext {
106 llm_client: OllamaClient,
107 app_state: Arc<AppState>,
108 status_tx: mpsc::UnboundedSender<String>,
109 protocol: Arc<GitProtocol>,
110 connection_id: ConnectionId,
111 server_id: crate::state::ServerId,
112 remote_addr: SocketAddr,
113 default_branch: String,
114}
115
116pub struct GitServer;
118
119impl GitServer {
120 pub async fn spawn_with_llm_actions(
122 listen_addr: SocketAddr,
123 llm_client: OllamaClient,
124 app_state: Arc<AppState>,
125 status_tx: mpsc::UnboundedSender<String>,
126 default_branch: String,
127 server_id: crate::state::ServerId,
128 ) -> anyhow::Result<SocketAddr> {
129 let listener =
130 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
131 let local_addr = listener.local_addr()?;
132 Log::new(Some(&status_tx)).info(format!("Git server listening on {}", local_addr));
133
134 let protocol = Arc::new(GitProtocol::new());
135
136 let task_registrar = app_state.clone();
138 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
139 let accept_handle = tokio::spawn(async move {
140 loop {
141 match crate::server::accept_bounded::accept_bounded(
142 &listener,
143 &limiter,
144 CONNECTION_CAP_REFUSAL,
145 "Git",
146 Some(&status_tx),
147 )
148 .await
149 {
150 Ok((stream, remote_addr, permit)) => {
151 let connection_id =
152 ConnectionId::new(app_state.get_next_unified_id().await);
153 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
154 Log::new(Some(&status_tx))
155 .info(format!("Git connection from {}", remote_addr));
156
157 use crate::state::server::{
159 ConnectionState as ServerConnectionState, ConnectionStatus,
160 ProtocolConnectionInfo,
161 };
162 let now = crate::utils::clock::Instant::now();
163 let conn_state = ServerConnectionState {
164 id: connection_id,
165 remote_addr,
166 local_addr: local_addr_conn,
167 bytes_sent: 0,
168 bytes_received: 0,
169 packets_sent: 0,
170 packets_received: 0,
171 last_activity: now,
172 status: ConnectionStatus::Active,
173 status_changed_at: now,
174 protocol_info: ProtocolConnectionInfo::empty(),
175 };
176 app_state
177 .add_connection_to_server(server_id, conn_state)
178 .await;
179 let _ = status_tx.send("__UPDATE_UI__".to_string());
180
181 let llm_client_clone = llm_client.clone();
182 let app_state_clone = app_state.clone();
183 let status_tx_clone = status_tx.clone();
184 let protocol_clone = protocol.clone();
185 let default_branch_clone = default_branch.clone();
186
187 let task_owner = app_state.clone();
190 task_owner
191 .spawn_server_task(server_id, async move {
192 let _permit = permit;
196
197 match tokio::time::timeout(
204 FIRST_BYTE_READ_TIMEOUT,
205 stream.peek(&mut [0u8; 1]),
206 )
207 .await
208 {
209 Ok(Ok(0)) | Ok(Err(_)) => {
210 app_state_clone
211 .close_connection_on_server(server_id, connection_id)
212 .await;
213 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
214 return;
215 }
216 Ok(Ok(_)) => {}
217 Err(_) => {
218 Log::new(Some(&status_tx_clone)).debug(format!(
219 "Git peer {} sent nothing for {}s; closing before \
220 the request line",
221 remote_addr,
222 FIRST_BYTE_READ_TIMEOUT.as_secs()
223 ));
224 app_state_clone
225 .close_connection_on_server(server_id, connection_id)
226 .await;
227 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
228 return;
229 }
230 }
231
232 let io = TokioIo::new(stream);
233
234 let status_for_service = status_tx_clone.clone();
236 let app_state_for_service = app_state_clone.clone();
237
238 let activity = Arc::new(
244 crate::server::accept_bounded::ConnectionActivity::new(),
245 );
246 let activity_for_service = Arc::clone(&activity);
247
248 let service = service_fn(move |req: Request<Incoming>| {
250 let ctx = RequestContext {
251 llm_client: llm_client_clone.clone(),
252 app_state: app_state_for_service.clone(),
253 status_tx: status_for_service.clone(),
254 protocol: protocol_clone.clone(),
255 connection_id,
256 server_id,
257 remote_addr,
258 default_branch: default_branch_clone.clone(),
259 };
260 let activity = Arc::clone(&activity_for_service);
261 async move {
262 let _busy = activity.busy();
263 handle_git_request(req, ctx).await
264 }
265 });
266
267 let conn = http1::Builder::new().serve_connection(io, service);
269 tokio::pin!(conn);
270 tokio::select! {
271 result = &mut conn => {
272 if let Err(err) = result {
273 error!("Error serving Git connection: {:?}", err);
274 }
275 }
276 _ = crate::server::accept_bounded::watch_idle(
277 Arc::clone(&activity),
278 IDLE_BETWEEN_REQUESTS_TIMEOUT,
279 ) => {
280 debug!(
281 "Git connection {} idle for {}s; closing",
282 connection_id,
283 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
284 );
285 }
286 }
287
288 app_state_clone
290 .close_connection_on_server(server_id, connection_id)
291 .await;
292 Log::new(Some(&status_tx_clone))
293 .info(format!("Git connection {} closed", connection_id));
294 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
295 })
296 .await;
297 }
298 Err(e) => {
299 Log::new(Some(&status_tx))
300 .error(format!("Failed to accept Git connection: {}", e));
301 break;
302 }
303 }
304 }
305 });
306
307 task_registrar
308 .register_server_task(server_id, accept_handle)
309 .await;
310
311 Ok(local_addr)
312 }
313}
314
315async fn handle_git_request(
317 req: Request<Incoming>,
318 ctx: RequestContext,
319) -> Result<Response<Full<Bytes>>, Infallible> {
320 let method = req.method().clone();
321 let uri = req.uri().clone();
322 let path = uri.path().to_string();
323 let user_agent = req
324 .headers()
325 .get(hyper::header::USER_AGENT)
326 .and_then(|v| v.to_str().ok())
327 .unwrap_or("")
328 .to_string();
329
330 Log::new(Some(&ctx.status_tx)).debug(format!("Git {} {}", method, path));
331
332 let repo = parse_repo_name(&path);
335
336 track_repo_access(&ctx.app_state, ctx.server_id, ctx.connection_id, &repo).await;
338
339 match (&method, path.as_str()) {
341 (&Method::GET, p) if p.ends_with("/info/refs") => {
343 let query = uri.query().unwrap_or("");
344 if query.contains("service=git-upload-pack") {
345 Ok(handle_info_refs(&ctx, &repo, &user_agent).await)
346 } else if query.contains("service=git-receive-pack") {
347 Ok(build_error_response(
349 StatusCode::FORBIDDEN,
350 "Push is not supported: this server implements git-upload-pack only",
351 ))
352 } else {
353 Ok(build_error_response(
355 StatusCode::FORBIDDEN,
356 "Dumb HTTP protocol not supported, use Smart HTTP (git-upload-pack service)",
357 ))
358 }
359 }
360
361 (&Method::POST, p) if p.ends_with("/git-upload-pack") => {
363 let content_encoding = req
364 .headers()
365 .get(hyper::header::CONTENT_ENCODING)
366 .and_then(|v| v.to_str().ok())
367 .unwrap_or("")
368 .to_string();
369
370 let limited = http_body_util::Limited::new(req.into_body(), MAX_UPLOAD_PACK_BYTES);
373 let body_bytes = match limited.collect().await {
374 Ok(collected) => collected.to_bytes(),
375 Err(e) => {
376 Log::new(Some(&ctx.status_tx)).error(format!(
377 "Git refusing upload-pack body ({}); limit is {} bytes",
378 e, MAX_UPLOAD_PACK_BYTES
379 ));
380 return Ok(build_error_response(
381 StatusCode::PAYLOAD_TOO_LARGE,
382 "git-upload-pack request body is too large",
383 ));
384 }
385 };
386
387 Log::new(Some(&ctx.status_tx)).trace(format!(
388 "Git upload-pack request body ({} bytes, encoding {:?})",
389 body_bytes.len(),
390 content_encoding
391 ));
392
393 Ok(handle_upload_pack(&ctx, &repo, &body_bytes, &content_encoding).await)
394 }
395
396 (_, p) if p.ends_with("/git-receive-pack") => Ok(build_error_response(
398 StatusCode::FORBIDDEN,
399 "Push is not supported: this server implements git-upload-pack only",
400 )),
401
402 _ => Ok(build_error_response(
404 StatusCode::NOT_FOUND,
405 &format!("Endpoint not found: {} {}", method, path),
406 )),
407 }
408}
409
410fn parse_repo_name(path: &str) -> String {
412 let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
417
418 match parts.first() {
419 None | Some(&"") | Some(&"info") | Some(&"git-upload-pack") | Some(&"git-receive-pack") => {
420 "default".to_string()
421 }
422 Some(name) => (*name).to_string(),
423 }
424}
425
426async fn handle_info_refs(
428 ctx: &RequestContext,
429 repo: &str,
430 user_agent: &str,
431) -> Response<Full<Bytes>> {
432 let event = Event::new(
433 &GIT_INFO_REFS_EVENT,
434 serde_json::json!({
435 "repository": repo,
436 "user_agent": user_agent,
437 "client_ip": ctx.remote_addr.ip().to_string(),
438 }),
439 );
440
441 let outcome = match resolve_repository(ctx, &event).await {
442 Ok(outcome) => outcome,
443 Err(response) => return response,
444 };
445
446 let repo_build = match outcome {
447 Outcome::Repository(build) => build,
448 Outcome::Error(response) => return response,
449 };
450
451 let body = build_refs_advertisement(&repo_build);
452 Log::new(Some(&ctx.status_tx)).info(format!(
453 "Git advertised {} at {} for '{}'",
454 repo_build.branch,
455 &repo_build.commit_hex()[..8],
456 repo
457 ));
458 record_bytes_sent(ctx, body.len()).await;
459
460 Response::builder()
461 .status(StatusCode::OK)
462 .header(
463 "Content-Type",
464 "application/x-git-upload-pack-advertisement",
465 )
466 .header("Cache-Control", "no-cache")
467 .body(Full::new(Bytes::from(body)))
468 .expect("static header values are valid")
469}
470
471async fn handle_upload_pack(
473 ctx: &RequestContext,
474 repo: &str,
475 body: &[u8],
476 content_encoding: &str,
477) -> Response<Full<Bytes>> {
478 record_bytes_received(ctx, body.len()).await;
479
480 let compressed = !content_encoding.is_empty() && content_encoding != "identity";
484 if compressed {
485 debug!(
486 "Git upload-pack body uses Content-Encoding: {} - negotiation details unavailable",
487 content_encoding
488 );
489 }
490
491 let request = if compressed {
492 pktline::UploadPackRequest::default()
493 } else {
494 pktline::parse_upload_pack_request(body)
495 };
496
497 if !request.done && !request.haves.is_empty() {
500 debug!(
501 "Git upload-pack negotiation round ({} haves, no done) - replying NAK",
502 request.haves.len()
503 );
504 let mut body = Vec::new();
505 body.extend_from_slice(&pktline::encode(b"NAK\n"));
506 return upload_pack_response(ctx, body).await;
507 }
508
509 let event = Event::new(
510 &GIT_UPLOAD_PACK_EVENT,
511 serde_json::json!({
512 "repository": repo,
513 "wants": request.wants,
514 "haves": request.haves,
515 "capabilities": request.capabilities,
516 "client_ip": ctx.remote_addr.ip().to_string(),
517 }),
518 );
519
520 let outcome = match resolve_repository(ctx, &event).await {
521 Ok(outcome) => outcome,
522 Err(response) => return response,
523 };
524
525 let repo_build = match outcome {
526 Outcome::Repository(build) => build,
527 Outcome::Error(response) => return response,
528 };
529
530 let commit_hex = repo_build.commit_hex();
531 if !request.wants.is_empty() && !request.wants.contains(&commit_hex) {
532 Log::new(Some(&ctx.status_tx)).error(format!(
535 "Git '{}': advertised commit and packed commit differ ({} vs {}); clone will \
536 fail. The git_info_refs and git_upload_pack events returned different repository \
537 content. Pin the repository content with a static event handler.",
538 repo,
539 request.wants.first().map(|w| &w[..8]).unwrap_or("?"),
540 &commit_hex[..8]
541 ));
542 }
543
544 let pack = write_pack(&repo_build.objects);
545 debug!(
546 "Git pack for '{}': {} objects, {} bytes, commit {}",
547 repo,
548 repo_build.objects.len(),
549 pack.len(),
550 commit_hex
551 );
552
553 let mut body = Vec::with_capacity(pack.len() + 16);
554 body.extend_from_slice(&pktline::encode(b"NAK\n"));
555 body.extend_from_slice(&pack);
556
557 Log::new(Some(&ctx.status_tx)).info(format!(
558 "Git sent pack for '{}' ({} objects, {} bytes)",
559 repo,
560 repo_build.objects.len(),
561 pack.len()
562 ));
563
564 upload_pack_response(ctx, body).await
565}
566
567async fn upload_pack_response(ctx: &RequestContext, body: Vec<u8>) -> Response<Full<Bytes>> {
568 record_bytes_sent(ctx, body.len()).await;
569 Response::builder()
570 .status(StatusCode::OK)
571 .header("Content-Type", "application/x-git-upload-pack-result")
572 .header("Cache-Control", "no-cache")
573 .body(Full::new(Bytes::from(body)))
574 .expect("static header values are valid")
575}
576
577enum Outcome {
579 Repository(BuiltRepo),
580 Error(Response<Full<Bytes>>),
581}
582
583async fn resolve_repository(
593 ctx: &RequestContext,
594 event: &Event,
595) -> Result<Outcome, Response<Full<Bytes>>> {
596 let execution_result = match call_llm(
597 &ctx.llm_client,
598 &ctx.app_state,
599 ctx.server_id,
600 Some(ctx.connection_id),
601 event,
602 ctx.protocol.as_ref(),
603 )
604 .await
605 {
606 Ok(result) => result,
607 Err(e) => {
608 let failure = WireFailure::classify(&e);
609 let decision = if failure.is_overloaded() {
610 "fail_closed_overloaded"
611 } else {
612 "fail_closed_llm_error"
613 };
614 Log::new(Some(&ctx.status_tx)).error(format!(
615 "Git {} decision={} error: {}",
616 event.id(),
617 decision,
618 e
619 ));
620 return Err(if failure.is_overloaded() {
621 build_retryable_error_response(failure.text())
622 } else {
623 build_error_response(StatusCode::INTERNAL_SERVER_ERROR, failure.text())
624 });
625 }
626 };
627
628 for message in &execution_result.messages {
629 Log::new(Some(&ctx.status_tx)).info(format!("{}", message));
630 }
631
632 for result in execution_result.protocol_results {
633 match result {
634 ActionResult::Custom { name, data } if name == "git_repository_response" => {
635 match snapshot_to_repo(&data, &ctx.default_branch) {
636 Ok(build) => return Ok(Outcome::Repository(build)),
637 Err(e) => {
638 Log::new(Some(&ctx.status_tx))
639 .error(format!("Git: invalid repository description: {}", e));
640 return Err(build_error_response(
641 StatusCode::INTERNAL_SERVER_ERROR,
642 &format!("Invalid repository description: {}", e),
643 ));
644 }
645 }
646 }
647 ActionResult::Custom { name, data } if name == "git_error_response" => {
648 let message = data
649 .get("message")
650 .and_then(|v| v.as_str())
651 .unwrap_or("Error");
652 let code = data.get("code").and_then(|v| v.as_u64()).unwrap_or(500) as u16;
653 Log::new(Some(&ctx.status_tx)).info(format!(
656 "Git {} decision=model_reject code={}",
657 event.id(),
658 code
659 ));
660 return Ok(Outcome::Error(build_error_response(
661 StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
662 message,
663 )));
664 }
665 _ => {}
666 }
667 }
668
669 Log::new(Some(&ctx.status_tx)).warn(format!(
671 "Git {} decision=fail_closed_no_action (expected git_repository or git_error)",
672 event.id()
673 ));
674 Err(build_error_response(
675 StatusCode::INTERNAL_SERVER_ERROR,
676 WireFailure::Unavailable.text(),
677 ))
678}
679
680fn snapshot_to_repo(data: &Value, default_branch: &str) -> anyhow::Result<BuiltRepo> {
682 let branch = data
683 .get("branch")
684 .and_then(|v| v.as_str())
685 .filter(|b| !b.is_empty())
686 .unwrap_or(default_branch);
687
688 let mut files = Vec::new();
689 if let Some(entries) = data.get("files").and_then(|v| v.as_array()) {
690 for entry in entries {
691 let path = entry
692 .get("path")
693 .and_then(|v| v.as_str())
694 .ok_or_else(|| anyhow::anyhow!("A file entry has no 'path'"))?;
695 let content = entry
696 .get("content")
697 .and_then(|v| v.as_str())
698 .unwrap_or("")
699 .to_string();
700 let executable = entry
701 .get("executable")
702 .and_then(|v| v.as_bool())
703 .unwrap_or(false);
704 files.push(RepoFile {
705 path: path.to_string(),
706 content: content.into_bytes(),
707 executable,
708 });
709 }
710 }
711
712 let meta = CommitMeta {
713 message: data
714 .get("commit_message")
715 .and_then(|v| v.as_str())
716 .unwrap_or("Initial commit")
717 .to_string(),
718 author_name: data
719 .get("author_name")
720 .and_then(|v| v.as_str())
721 .unwrap_or("NetGet")
722 .to_string(),
723 author_email: data
724 .get("author_email")
725 .and_then(|v| v.as_str())
726 .unwrap_or("netget@localhost")
727 .to_string(),
728 timestamp: data
729 .get("timestamp")
730 .and_then(|v| v.as_i64())
731 .unwrap_or(DEFAULT_COMMIT_TIMESTAMP),
732 };
733
734 build_repo(branch, &files, &meta)
735}
736
737fn build_refs_advertisement(repo: &BuiltRepo) -> Vec<u8> {
739 let sha = hex_id(&repo.commit_id);
740 let branch_ref = format!("refs/heads/{}", repo.branch);
741
742 let mut out = Vec::new();
743 out.extend_from_slice(&pktline::encode(b"# service=git-upload-pack\n"));
744 out.extend_from_slice(pktline::FLUSH);
745
746 let capabilities = format!("{BASE_CAPABILITIES} symref=HEAD:{branch_ref}");
749 out.extend_from_slice(&pktline::encode(
750 format!("{sha} HEAD\0{capabilities}\n").as_bytes(),
751 ));
752 out.extend_from_slice(&pktline::encode(format!("{sha} {branch_ref}\n").as_bytes()));
753 out.extend_from_slice(pktline::FLUSH);
754 out
755}
756
757async fn track_repo_access(
759 app_state: &Arc<AppState>,
760 server_id: crate::state::ServerId,
761 connection_id: ConnectionId,
762 repo_name: &str,
763) {
764 app_state
765 .with_server_mut(server_id, |server| {
766 if let Some(conn) = server.connections.get_mut(&connection_id) {
767 if let Some(obj) = conn.protocol_info.data.as_object_mut() {
768 let mut recent_repos: Vec<String> = obj
769 .get("recent_repos")
770 .and_then(|v| serde_json::from_value(v.clone()).ok())
771 .unwrap_or_default();
772 if !recent_repos.contains(&repo_name.to_string()) {
773 recent_repos.push(repo_name.to_string());
774 if recent_repos.len() > 10 {
776 recent_repos.remove(0);
777 }
778 }
779 obj.insert(
780 "recent_repos".to_string(),
781 serde_json::to_value(&recent_repos).unwrap_or(serde_json::json!([])),
782 );
783 }
784 }
785 })
786 .await;
787}
788
789async fn record_bytes_sent(ctx: &RequestContext, len: usize) {
790 ctx.app_state
791 .update_connection_stats(
792 ctx.server_id,
793 ctx.connection_id,
794 None,
795 Some(len as u64),
796 None,
797 Some(1),
798 )
799 .await;
800}
801
802async fn record_bytes_received(ctx: &RequestContext, len: usize) {
803 ctx.app_state
804 .update_connection_stats(
805 ctx.server_id,
806 ctx.connection_id,
807 Some(len as u64),
808 None,
809 Some(1),
810 None,
811 )
812 .await;
813}
814
815fn build_error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
817 Response::builder()
818 .status(status)
819 .header("Content-Type", "text/plain")
820 .body(Full::new(Bytes::from(format!("Error: {}\n", message))))
821 .expect("static header values are valid")
822}
823
824fn build_retryable_error_response(message: &str) -> Response<Full<Bytes>> {
828 Response::builder()
829 .status(StatusCode::SERVICE_UNAVAILABLE)
830 .header("Content-Type", "text/plain")
831 .header(hyper::header::RETRY_AFTER, "1")
832 .body(Full::new(Bytes::from(format!("Error: {}\n", message))))
833 .expect("static header values are valid")
834}