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