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