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