anvilsign in

collin/anvil

1//! git-over-SSH transport for anvil, built on `russh` (pure Rust — no OpenSSH).
2//!
3//! Authenticates by public key, then handles `git-upload-pack` /
4//! `git-receive-pack` `exec` requests by running the transport-agnostic engine
5//! in [`anvil_git::ssh`] over the channel's byte stream.
6//!
7//! The SSH bind address is configurable (`[ssh] listen` in the config).
8
9use std::net::SocketAddr;
10use std::path::{
11 Path,
12 PathBuf,
13};
14use std::sync::Arc;
15use std::time::Duration;
16
17use anvil_core::{
18 App,
19 Error as CoreError,
20 Result as CoreResult,
21 access,
22 repos,
23 users,
24};
25use anvil_git::ssh as git_ssh;
26use russh::keys::ssh_key::{
27 HashAlg,
28 LineEnding,
29 PublicKey,
30};
31use russh::keys::{
32 Algorithm,
33 PrivateKey,
34};
35use russh::server::{
36 self,
37 Auth,
38 Handler,
39 Msg,
40 Server as _,
41 Session,
42};
43use russh::{
44 Channel,
45 ChannelId,
46};
47use tokio::net::TcpListener;
48
49/// Bind to the configured SSH address and serve git over SSH until shutdown.
50pub async fn serve(app: App) -> CoreResult<()> {
51 let listen = app.config.ssh.listen.clone();
52 let host_key = load_or_create_host_key(&app.config.data_dir)?;
53
54 let config = Arc::new(server::Config {
55 inactivity_timeout: Some(Duration::from_secs(3600)),
56 auth_rejection_time: Duration::from_secs(1),
57 keys: vec![host_key],
58 ..Default::default()
59 });
60
61 let listener = TcpListener::bind(&listen).await?;
62 tracing::info!("anvil ssh server listening on {listen}");
63
64 let mut server = GitSshServer { app };
65 server
66 .run_on_socket(config, &listener)
67 .await
68 .map_err(|e| CoreError::Storage(format!("ssh server: {e}")))?;
69 Ok(())
70}
71
72/// Load the persistent ed25519 host key, generating and saving it on first run
73/// so the server identity is stable across restarts.
74fn load_or_create_host_key(data_dir: &Path) -> CoreResult<PrivateKey> {
75 let path = data_dir.join("ssh_host_ed25519_key");
76 if path.exists() {
77 let pem = std::fs::read_to_string(&path)?;
78 return PrivateKey::from_openssh(&pem).map_err(|e| {
79 CoreError::Config(format!("reading ssh host key {}: {e}", path.display()))
80 });
81 }
82
83 let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)
84 .map_err(|e| CoreError::Config(format!("generating ssh host key: {e}")))?;
85 let pem = key
86 .to_openssh(LineEnding::LF)
87 .map_err(|e| CoreError::Config(format!("encoding ssh host key: {e}")))?;
88 std::fs::write(&path, pem.as_bytes())?;
89 #[cfg(unix)]
90 {
91 use std::os::unix::fs::PermissionsExt;
92 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
93 }
94 tracing::info!("generated ssh host key at {}", path.display());
95 Ok(key)
96}
97
98struct GitSshServer {
99 app: App,
100}
101
102impl server::Server for GitSshServer {
103 type Handler = GitSshSession;
104
105 fn new_client(&mut self, _peer: Option<SocketAddr>) -> GitSshSession {
106 GitSshSession {
107 app: self.app.clone(),
108 channel: None,
109 protocol_v2: false,
110 authed_user: None,
111 }
112 }
113}
114
115struct GitSshSession {
116 app: App,
117 /// The session channel, taken in `channel_open_session` and consumed by the
118 /// git protocol task in `exec_request`.
119 channel: Option<Channel<Msg>>,
120 /// Set if the client requested git protocol v2 via the `GIT_PROTOCOL` env.
121 protocol_v2: bool,
122 /// The user id authenticated by public key, set in `auth_publickey`.
123 authed_user: Option<i64>,
124}
125
126impl Handler for GitSshSession {
127 type Error = russh::Error;
128
129 async fn auth_publickey(&mut self, _user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
130 // Authenticate by matching the (signature-verified) key's fingerprint to
131 // a registered user. Unknown keys are rejected. Per-repo authorization
132 // is a later milestone; for now any authenticated user has full access.
133 let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
134 match anvil_core::ssh_keys::find_user_id_by_fingerprint(&self.app.db, &fingerprint).await {
135 Ok(Some(user_id)) => {
136 self.authed_user = Some(user_id);
137 tracing::info!("ssh auth: accepted key {fingerprint} (user {user_id})");
138 Ok(Auth::Accept)
139 }
140 Ok(None) => {
141 tracing::info!("ssh auth: rejected unknown key {fingerprint}");
142 Ok(Auth::reject())
143 }
144 Err(e) => {
145 tracing::error!("ssh auth: key lookup failed: {e}");
146 Ok(Auth::reject())
147 }
148 }
149 }
150
151 async fn channel_open_session(
152 &mut self,
153 channel: Channel<Msg>,
154 _session: &mut Session,
155 ) -> Result<bool, Self::Error> {
156 self.channel = Some(channel);
157 Ok(true)
158 }
159
160 async fn env_request(
161 &mut self,
162 _channel: ChannelId,
163 name: &str,
164 value: &str,
165 _session: &mut Session,
166 ) -> Result<(), Self::Error> {
167 if name == "GIT_PROTOCOL" && value.split(':').any(|v| v.trim() == "version=2") {
168 self.protocol_v2 = true;
169 }
170 Ok(())
171 }
172
173 async fn exec_request(
174 &mut self,
175 channel_id: ChannelId,
176 data: &[u8],
177 session: &mut Session,
178 ) -> Result<(), Self::Error> {
179 let command = String::from_utf8_lossy(data);
180 let Some((service, rel)) = git_ssh::parse_command(&command) else {
181 return fail(session, channel_id, "unsupported command");
182 };
183 let need_write = service == anvil_git::Service::ReceivePack;
184 let (path, repo_id) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await
185 {
186 Ok(resolved) => resolved,
187 Err(message) => return fail(session, channel_id, message),
188 };
189 let Some(channel) = self.channel.take() else {
190 return fail(session, channel_id, "no session channel");
191 };
192
193 tracing::info!(
194 "ssh {} on {rel} (user {:?})",
195 service.as_str(),
196 self.authed_user
197 );
198
199 let protocol_v2 = self.protocol_v2;
200 let handle = session.handle();
201 let db = self.app.db.clone();
202 session.channel_success(channel_id)?;
203
204 tokio::spawn(async move {
205 // For a push, snapshot branch tips before serving so we can detect
206 // what changed and trigger CI afterward.
207 let before = (service == anvil_git::Service::ReceivePack)
208 .then(|| anvil_git::trigger::snapshot_branches(&path));
209
210 let stream = channel.into_stream();
211 let code = match git_ssh::serve(&path, service, protocol_v2, stream).await {
212 Ok(()) => 0,
213 Err(e) => {
214 tracing::error!("git ssh {}: {e}", service.as_str());
215 1
216 }
217 };
218
219 if code == 0 {
220 if let Some(before) = before {
221 anvil_git::trigger::enqueue_ci_for_push(&db, repo_id, &path, &before).await;
222 }
223 }
224
225 let _ = handle.exit_status_request(channel_id, code).await;
226 let _ = handle.eof(channel_id).await;
227 let _ = handle.close(channel_id).await;
228 });
229 Ok(())
230 }
231}
232
233/// Write a one-line error to the channel and close it with a failure status.
234fn fail(session: &mut Session, channel_id: ChannelId, message: &str) -> Result<(), russh::Error> {
235 let _ = session.data(channel_id, format!("{message}\n").into_bytes());
236 session.exit_status_request(channel_id, 1)?;
237 session.close(channel_id)?;
238 Ok(())
239}
240
241/// Resolve an SSH repo path (`owner/name[.git]`) to an existing bare repo and
242/// enforce access for the authenticated user. Returns the on-disk path or a
243/// short error message. Rejects traversal.
244async fn authorize_repo(
245 app: &App,
246 authed_user: Option<i64>,
247 rel: &str,
248 need_write: bool,
249) -> Result<(PathBuf, i64), &'static str> {
250 let (owner, repo) = rel
251 .trim_start_matches('/')
252 .split_once('/')
253 .ok_or("invalid repository path")?;
254 let name = repo.strip_suffix(".git").unwrap_or(repo);
255 let bad = |s: &str| s.is_empty() || s.contains("..") || s.contains('\\') || s.contains('/');
256 if bad(owner) || bad(name) {
257 return Err("invalid repository path");
258 }
259
260 let owner_user = users::find_by_username(&app.db, owner)
261 .await
262 .ok()
263 .flatten()
264 .ok_or("repository not found")?;
265 let repo = repos::find(&app.db, owner_user.id, name)
266 .await
267 .ok()
268 .flatten()
269 .ok_or("repository not found")?;
270
271 let viewer = match authed_user {
272 Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(),
273 None => None,
274 };
275 let allowed = if need_write {
276 access::can_write(&repo, viewer.as_ref())
277 } else {
278 access::can_read(&repo, viewer.as_ref())
279 };
280 if !allowed {
281 return Err("access denied");
282 }
283
284 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
285 if !path.exists() {
286 return Err("repository not found");
287 }
288 Ok((path, repo.id))
289}