zuka
zuka/src/ssh/mod.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/ssh/mod.rs
RSmod.rs14.2 KBDownload
1// SSH transport.
2//
3// The credential is a public key, so there is no password path and no keyboard
4// -interactive path — both are rejected outright. The account is derived from
5// *which stored key matched the offered one*, never from the SSH username, which is
6// attacker-controlled and is always `git` in practice.
7//
8// The only thing an authenticated session may do is exec `git-upload-pack` or
9// `git-receive-pack` against a repository it is authorized for. There is no shell,
10// no subsystem, no pty, and the environment handed to the child is an allowlist of
11// one variable.
12
13mod command;
14
15pub use command::{parse_command, GitCommand};
16
17use crate::account::key::{self, KeyRecord};
18use crate::account::Identity;
19use crate::brand;
20use crate::error::Result;
21use crate::git::validate;
22use crate::http::AppState;
23use anyhow::Context;
24use russh::keys::PrivateKey;
25use russh::server::{Auth, Config, Handler, Msg, Server, Session};
26use russh::{Channel, ChannelId};
27use std::collections::HashMap;
28use std::net::SocketAddr;
29use std::process::Stdio;
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::process::Command;
33
34/// Load the host key, generating one on first boot.
35///
36/// A server that regenerates its host key every start trains users to accept a
37/// changed fingerprint, which is the warning that matters.
38pub fn load_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKey> {
39 if path.exists() {
40 let pem = std::fs::read_to_string(path)
41 .with_context(|| format!("read host key {}", path.display()))?;
42 return PrivateKey::from_openssh(&pem)
43 .with_context(|| format!("parse host key {}", path.display()));
44 }
45
46 // Seeded straight from the OS CSPRNG. `PrivateKey::random` wants an RNG from a
47 // `rand_core` version whose `OsRng` this dependency graph does not compile in,
48 // and pulling in a second RNG stack to satisfy a type bound is not worth it.
49 let mut seed = [0u8; 32];
50 getrandom::fill(&mut seed).map_err(|e| anyhow::anyhow!("read OS entropy: {e}"))?;
51 let key = PrivateKey::from(russh::keys::ssh_key::private::Ed25519Keypair::from_seed(
52 &seed,
53 ));
54 let pem = key
55 .to_openssh(russh::keys::ssh_key::LineEnding::LF)
56 .context("encode host key")?;
57
58 if let Some(dir) = path.parent() {
59 std::fs::create_dir_all(dir)?;
60 }
61 crate::account::token::write_private(path, pem.as_bytes())
62 .map_err(|e| anyhow::anyhow!("{e}"))?;
63 eprintln!("[ssh] generated a host key at {}", path.display());
64 Ok(key)
65}
66
67pub struct SshServer {
68 state: Arc<AppState>,
69}
70
71impl SshServer {
72 pub fn new(state: Arc<AppState>) -> Self {
73 SshServer { state }
74 }
75
76 /// Bind and serve until the process exits.
77 pub async fn run(
78 mut self,
79 bind: SocketAddr,
80 host_key: PrivateKey,
81 idle_timeout: Duration,
82 ) -> anyhow::Result<()> {
83 let config = Arc::new(Config {
84 // An unauthenticated connection must not be able to hold a slot forever.
85 inactivity_timeout: Some(idle_timeout),
86 auth_rejection_time: Duration::from_secs(2),
87 auth_rejection_time_initial: Some(Duration::from_secs(0)),
88 keys: vec![host_key],
89 ..Default::default()
90 });
91
92 let listener = tokio::net::TcpListener::bind(bind)
93 .await
94 .with_context(|| format!("bind ssh {bind}"))?;
95 eprintln!("[ssh] listening on ssh://{bind}");
96
97 self.run_on_socket(config, &listener).await?;
98 Ok(())
99 }
100}
101
102impl Server for SshServer {
103 type Handler = SshSession;
104
105 fn new_client(&mut self, peer: Option<SocketAddr>) -> SshSession {
106 SshSession {
107 state: Arc::clone(&self.state),
108 peer,
109 identity: None,
110 key: None,
111 git_protocol: None,
112 channels: HashMap::new(),
113 }
114 }
115}
116
117pub struct SshSession {
118 state: Arc<AppState>,
119 peer: Option<SocketAddr>,
120 identity: Option<Identity>,
121 key: Option<KeyRecord>,
122 /// The one environment variable a client may set. Anything else is dropped.
123 git_protocol: Option<String>,
124 channels: HashMap<ChannelId, Channel<Msg>>,
125}
126
127impl SshSession {
128 /// Reject with a message the user actually sees, then fail the channel.
129 async fn refuse(&self, channel: &Channel<Msg>, message: &str) -> Result<()> {
130 let text = brand::say(format_args!("{message}\r\n"));
131 let _ = channel.extended_data(1, text.as_bytes()).await;
132 let _ = channel.exit_status(1).await;
133 let _ = channel.eof().await;
134 let _ = channel.close().await;
135 Ok(())
136 }
137}
138
139impl Handler for SshSession {
140 type Error = russh::Error;
141
142 /// Public keys are the only accepted credential.
143 ///
144 /// The offered key is matched against stored keys by its raw bytes; `user` is
145 /// ignored entirely. Accepting the username as an account claim would let anyone
146 /// holding any valid key act as any account.
147 async fn auth_publickey(
148 &mut self,
149 _user: &str,
150 offered: &russh::keys::ssh_key::PublicKey,
151 ) -> std::result::Result<Auth, Self::Error> {
152 let algorithm = offered.algorithm().to_string();
153 let Ok(blob) = offered.to_bytes() else {
154 return Ok(Auth::reject());
155 };
156
157 let keys = self.state.ssh_keys();
158 let Some(record) = keys.find_by_blob(&algorithm, &blob) else {
159 eprintln!(
160 "[ssh] rejected unknown key {} from {}",
161 key::fingerprint(&blob),
162 self.peer.map(|p| p.to_string()).unwrap_or_default()
163 );
164 return Ok(Auth::reject());
165 };
166
167 let Ok(account) = validate::name(&record.account) else {
168 return Ok(Auth::reject());
169 };
170 let repos = match record
171 .repos
172 .iter()
173 .map(|r| validate::name(r))
174 .collect::<Result<Vec<_>>>()
175 {
176 Ok(repos) => repos,
177 Err(_) => return Ok(Auth::reject()),
178 };
179
180 // An SSH key carries its own scope, independent of any API token.
181 let scopes = if record.read_only {
182 vec![crate::account::Scope::RepoRead]
183 } else {
184 vec![crate::account::Scope::RepoWrite]
185 };
186
187 self.identity = Some(Identity {
188 account,
189 token_id: record.id.clone(),
190 scopes,
191 repos,
192 });
193 self.key = Some(record.clone());
194
195 eprintln!(
196 "[ssh] {} authenticated as {}",
197 record.fingerprint, record.account
198 );
199 Ok(Auth::Accept)
200 }
201
202 async fn auth_password(
203 &mut self,
204 _user: &str,
205 _password: &str,
206 ) -> std::result::Result<Auth, Self::Error> {
207 Ok(Auth::reject())
208 }
209
210 async fn channel_open_session(
211 &mut self,
212 channel: Channel<Msg>,
213 reply: russh::server::ChannelOpenHandle,
214 _session: &mut Session,
215 ) -> std::result::Result<(), Self::Error> {
216 reply.accept().await;
217 self.channels.insert(channel.id(), channel);
218 Ok(())
219 }
220
221 /// Accept `GIT_PROTOCOL` and nothing else.
222 ///
223 /// Forwarding arbitrary client environment into a subprocess is an injection
224 /// primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and
225 /// `LD_PRELOAD` all change what the child does.
226 async fn env_request(
227 &mut self,
228 _channel: ChannelId,
229 name: &str,
230 value: &str,
231 _session: &mut Session,
232 ) -> std::result::Result<(), Self::Error> {
233 if name == "GIT_PROTOCOL" && value.len() <= 64 {
234 self.git_protocol = Some(value.to_string());
235 }
236 Ok(())
237 }
238
239 /// No interactive shell, ever.
240 async fn shell_request(
241 &mut self,
242 channel: ChannelId,
243 _session: &mut Session,
244 ) -> std::result::Result<(), Self::Error> {
245 if let Some(channel) = self.channels.remove(&channel) {
246 let _ = self
247 .refuse(
248 &channel,
249 "this server provides git access only, not a shell",
250 )
251 .await;
252 }
253 Ok(())
254 }
255
256 async fn pty_request(
257 &mut self,
258 channel: ChannelId,
259 _: &str,
260 _: u32,
261 _: u32,
262 _: u32,
263 _: u32,
264 _: &[(russh::Pty, u32)],
265 _session: &mut Session,
266 ) -> std::result::Result<(), Self::Error> {
267 if let Some(channel) = self.channels.remove(&channel) {
268 let _ = self.refuse(&channel, "no pty is available").await;
269 }
270 Ok(())
271 }
272
273 async fn exec_request(
274 &mut self,
275 channel_id: ChannelId,
276 data: &[u8],
277 _session: &mut Session,
278 ) -> std::result::Result<(), Self::Error> {
279 let Some(channel) = self.channels.remove(&channel_id) else {
280 return Ok(());
281 };
282 let Some(identity) = self.identity.clone() else {
283 let _ = self.refuse(&channel, "not authenticated").await;
284 return Ok(());
285 };
286
287 let raw = String::from_utf8_lossy(data).to_string();
288 let command = match parse_command(&raw) {
289 Ok(command) => command,
290 Err(e) => {
291 let _ = self.refuse(&channel, &e.detail_for_user()).await;
292 return Ok(());
293 }
294 };
295
296 if let Err(e) = self.authorize(&identity, &command).await {
297 let _ = self.refuse(&channel, &user_message(&e)).await;
298 return Ok(());
299 }
300
301 let repo_path = match self.state.git.repo_path(&command.account, &command.repo) {
302 Ok(path) => path,
303 Err(e) => {
304 let _ = self.refuse(&channel, &user_message(&e)).await;
305 return Ok(());
306 }
307 };
308
309 let permit = match Arc::clone(&self.state.git_slots).acquire_owned().await {
310 Ok(permit) => permit,
311 Err(_) => {
312 let _ = self.refuse(&channel, "server is shutting down").await;
313 return Ok(());
314 }
315 };
316
317 let mut process = Command::new("git");
318 process
319 .env_clear()
320 .env(
321 "PATH",
322 std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
323 )
324 .env("GIT_CONFIG_NOSYSTEM", "1")
325 .arg(command.service.git_subcommand())
326 .arg(&repo_path)
327 .stdin(Stdio::piped())
328 .stdout(Stdio::piped())
329 .stderr(Stdio::piped())
330 .kill_on_drop(true);
331
332 #[cfg(unix)]
333 process.process_group(0);
334
335 if let Some(protocol) = &self.git_protocol {
336 process.env("GIT_PROTOCOL", protocol);
337 }
338 for (key, value) in self.state.config.hook_env() {
339 process.env(key, value);
340 }
341
342 let mut child = match process.spawn() {
343 Ok(child) => child,
344 Err(e) => {
345 eprintln!("[ssh] spawn git failed: {e}");
346 let _ = self.refuse(&channel, "could not start git").await;
347 return Ok(());
348 }
349 };
350 let pid = child.id();
351
352 tokio::spawn(async move {
353 let _permit = permit;
354 let _guard = ProcessGroupGuard { pid };
355
356 let mut stdin = child.stdin.take().expect("stdin is piped");
357 let mut stdout = child.stdout.take().expect("stdout is piped");
358 let mut stderr = child.stderr.take().expect("stderr is piped");
359
360 let (mut read_half, write_half) = channel.split();
361 let mut out_writer = write_half.make_writer();
362 let mut err_writer = write_half.make_writer_ext(Some(1));
363
364 // The client half is pumped in its own task and is deliberately NOT
365 // joined on. In a fetch the client sends its wants and then waits for
366 // the pack, so this copy does not return until the session ends —
367 // joining it would hang every clone that has something to send back.
368 let stdin_pump = tokio::spawn(async move {
369 let mut client = read_half.make_reader();
370 let _ = tokio::io::copy(&mut client, &mut stdin).await;
371 let _ = tokio::io::AsyncWriteExt::shutdown(&mut stdin).await;
372 });
373
374 // git writes sideband progress while still consuming the pack, so
375 // stdout and stderr must drain concurrently or a full pipe deadlocks it.
376 let _ = tokio::join!(
377 tokio::io::copy(&mut stdout, &mut out_writer),
378 tokio::io::copy(&mut stderr, &mut err_writer),
379 );
380
381 let code = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(1) as u32;
382
383 stdin_pump.abort();
384
385 let _ = write_half.exit_status(code).await;
386 let _ = write_half.eof().await;
387 let _ = write_half.close().await;
388 });
389
390 Ok(())
391 }
392}
393
394impl SshSession {
395 async fn authorize(&self, identity: &Identity, command: &GitCommand) -> Result<()> {
396 let access = if command.service.writes() {
397 crate::core::Access::Write
398 } else {
399 crate::core::Access::Read
400 };
401 self.state
402 .open_repo(identity, &command.account, &command.repo, access)?;
403 Ok(())
404 }
405}
406
407/// Kills the whole process group when the session task ends.
408///
409/// `upload-pack` forks `pack-objects`; killing only the direct child leaves the
410/// grandchild running against a pipe nobody reads.
411struct ProcessGroupGuard {
412 pid: Option<u32>,
413}
414
415impl Drop for ProcessGroupGuard {
416 fn drop(&mut self) {
417 #[cfg(unix)]
418 if let Some(pid) = self.pid {
419 // SAFETY: spawned with `process_group(0)`, so the pgid is the child's
420 // own pid and no unrelated process shares it.
421 unsafe {
422 libc::killpg(pid as libc::pid_t, libc::SIGKILL);
423 }
424 }
425 }
426}
427
428/// What to tell the user over SSH. Internal detail never crosses this line.
429fn user_message(error: &crate::error::Error) -> String {
430 match error.status() {
431 403 => "you do not have access to this repository".into(),
432 404 => "repository not found".into(),
433 507 => "the server is out of storage".into(),
434 s if s >= 500 => "the server failed to handle this request".into(),
435 _ => "request refused".into(),
436 }
437}