1use gix::protocol::transport::client::blocking_io::Transport;
2use std::sync::Arc;
3
4type BoxedTransport = Box<dyn Transport + Send>;
5type GitConnection<'a, 'b, 'c> = gix::remote::Connection<'a, 'b, 'c, BoxedTransport>;
6
7pub enum GitCredentials {
8 None,
9 Password {
10 username: compact_str::CompactString,
11 password: compact_str::CompactString,
12 },
13 PrivateKey {
14 username: compact_str::CompactString,
15 private_key: String,
16 passphrase: Option<compact_str::CompactString>,
17 },
18}
19
20struct AcceptAnyServerKey;
21
22impl russh::client::Handler for AcceptAnyServerKey {
23 type Error = russh::Error;
24
25 async fn check_server_key(
26 &mut self,
27 _server_public_key: &russh::keys::PublicKey,
28 ) -> Result<bool, Self::Error> {
29 Ok(true)
30 }
31}
32
33struct SessionBound<T> {
34 inner: T,
35 _session: russh::client::Handle<AcceptAnyServerKey>,
36}
37
38impl<T: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for SessionBound<T> {
39 fn poll_read(
40 mut self: std::pin::Pin<&mut Self>,
41 cx: &mut std::task::Context<'_>,
42 buf: &mut tokio::io::ReadBuf<'_>,
43 ) -> std::task::Poll<std::io::Result<()>> {
44 std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
45 }
46}
47
48pub async fn resolve_addresses(
49 env: &Arc<crate::env::Env>,
50 url: &gix::Url,
51) -> Result<Vec<std::net::SocketAddr>, anyhow::Error> {
52 let host = url
53 .host()
54 .ok_or_else(|| anyhow::anyhow!("repository url is missing a host"))?;
55 let port = url.port.unwrap_or(match url.scheme {
56 gix::url::Scheme::Ssh => 22,
57 gix::url::Scheme::Https => 443,
58 _ => 80,
59 });
60
61 crate::net::resolve_allowed_addresses(env, host, port, "git").await
62}
63
64pub fn parse_private_key(
65 private_key: &str,
66 passphrase: Option<&str>,
67) -> Result<russh::keys::PrivateKey, anyhow::Error> {
68 let key = russh::keys::PrivateKey::from_openssh(private_key)?;
69
70 if key.is_encrypted() {
71 let passphrase = passphrase.ok_or_else(|| {
72 anyhow::anyhow!("private key is encrypted, but no passphrase was set")
73 })?;
74
75 Ok(key.decrypt(passphrase)?)
76 } else {
77 Ok(key)
78 }
79}
80
81pub fn validate_private_key(private_key: &str, _context: &()) -> Result<(), garde::Error> {
82 match russh::keys::PrivateKey::from_openssh(private_key) {
83 Ok(_) => Ok(()),
84 Err(err) => Err(garde::Error::new(format!("Invalid private key: {err}"))),
85 }
86}
87
88enum SshAuth {
89 Password(compact_str::CompactString),
90 PrivateKey(Arc<russh::keys::PrivateKey>),
91}
92
93async fn ssh_upload_pack(
94 env: &Arc<crate::env::Env>,
95 url: &gix::Url,
96 username: &str,
97 auth: SshAuth,
98) -> Result<BoxedTransport, anyhow::Error> {
99 let path = String::from_utf8_lossy(&url.path).into_owned();
100 let addresses = resolve_addresses(env, url).await?;
101
102 let mut session = russh::client::connect(
103 Arc::new(russh::client::Config {
104 keepalive_interval: Some(std::time::Duration::from_secs(30)),
105 ..Default::default()
106 }),
107 addresses.as_slice(),
108 AcceptAnyServerKey,
109 )
110 .await?;
111
112 let authenticated = match auth {
113 SshAuth::Password(password) => {
114 session
115 .authenticate_password(username, password.as_str())
116 .await?
117 }
118 SshAuth::PrivateKey(key) => {
119 let hash_alg = session.best_supported_rsa_hash().await?.flatten();
120
121 session
122 .authenticate_publickey(
123 username,
124 russh::keys::PrivateKeyWithHashAlg::new(key, hash_alg),
125 )
126 .await?
127 }
128 };
129
130 if !authenticated.success() {
131 return Err(anyhow::anyhow!("ssh authentication was rejected"));
132 }
133
134 let channel = session.channel_open_session().await?;
135
136 let _ = channel
137 .set_env(false, "GIT_PROTOCOL", "version=2")
138 .await
139 .inspect_err(|err| tracing::debug!("failed to request GIT_PROTOCOL over ssh: {err:#}"));
140
141 channel
142 .exec(true, format!("git-upload-pack '{path}'"))
143 .await?;
144
145 let (read, write) = tokio::io::split(channel.into_stream());
146 let handle = tokio::runtime::Handle::current();
147
148 Ok(Box::new(
149 gix::protocol::transport::client::git::blocking_io::Connection::new(
150 tokio_util::io::SyncIoBridge::new_with_handle(
151 SessionBound {
152 inner: read,
153 _session: session,
154 },
155 handle.clone(),
156 ),
157 tokio_util::io::SyncIoBridge::new_with_handle(write, handle),
158 gix::protocol::transport::Protocol::V2,
159 path,
160 None::<(String, Option<u16>)>,
161 gix::protocol::transport::client::git::ConnectMode::Process,
162 false,
163 ),
164 ))
165}
166
167impl GitCredentials {
168 pub fn into_connection_configurator(
169 self,
170 env: Arc<crate::env::Env>,
171 url: gix::Url,
172 ) -> impl FnMut(
173 &mut GitConnection<'_, '_, '_>,
174 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
175 + 'static {
176 let over_ssh = url.scheme == gix::url::Scheme::Ssh;
177
178 move |connection| {
179 let (username, auth) = match &self {
180 GitCredentials::None => return Ok(()),
181 GitCredentials::Password { username, password } if !over_ssh => {
182 connection
183 .transport_mut()
184 .set_identity(gix::sec::identity::Account {
185 username: username.to_string(),
186 password: password.to_string(),
187 oauth_refresh_token: None,
188 })?;
189
190 return Ok(());
191 }
192 GitCredentials::Password { username, password } => {
193 (username, SshAuth::Password(password.clone()))
194 }
195 GitCredentials::PrivateKey {
196 username,
197 private_key,
198 passphrase,
199 } => (
200 username,
201 SshAuth::PrivateKey(Arc::new(parse_private_key(
202 private_key,
203 passphrase.as_deref(),
204 )?)),
205 ),
206 };
207
208 let transport = tokio::runtime::Handle::current()
209 .block_on(ssh_upload_pack(&env, &url, username, auth))?;
210
211 *connection.transport_mut() = transport;
212
213 Ok(())
214 }
215 }
216}