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
| 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 | |
| 13 | mod command; |
| 14 | |
| 15 | pub use command::{parse_command, GitCommand}; |
| 16 | |
| 17 | use crate::account::key::{self, KeyRecord}; |
| 18 | use crate::account::Identity; |
| 19 | use crate::brand; |
| 20 | use crate::error::Result; |
| 21 | use crate::git::validate; |
| 22 | use crate::http::AppState; |
| 23 | use anyhow::Context; |
| 24 | use russh::keys::PrivateKey; |
| 25 | use russh::server::{Auth, Config, Handler, Msg, Server, Session}; |
| 26 | use russh::{Channel, ChannelId}; |
| 27 | use std::collections::HashMap; |
| 28 | use std::net::SocketAddr; |
| 29 | use std::process::Stdio; |
| 30 | use std::sync::Arc; |
| 31 | use std::time::Duration; |
| 32 | use 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. |
| 38 | pub 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 | |
| 67 | pub struct SshServer { |
| 68 | state: Arc<AppState>, |
| 69 | } |
| 70 | |
| 71 | impl 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 | |
| 102 | impl 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 | |
| 117 | pub 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 | |
| 127 | impl 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 | |
| 139 | impl 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 | |
| 394 | impl 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. |
| 411 | struct ProcessGroupGuard { |
| 412 | pid: Option<u32>, |
| 413 | } |
| 414 | |
| 415 | impl 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. |
| 429 | fn 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 | } |