This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-ssh / src / lib.rs
11 kB 358 lines
1mod exec; 2mod roster; 3mod server; 4 5use std::collections::BTreeSet; 6use std::net::SocketAddr; 7use std::path::Path; 8use std::sync::Arc; 9use std::time::Duration; 10 11use knot_atproto::Atproto; 12use knot_events::EventLog; 13use knot_git::Layout; 14use knot_index::Index; 15use knot_maintenance::MaintenanceHandle; 16use knot_pack::{MaxWireBytes, PackLimits}; 17use knot_postreceive::LanguagesPushBudget; 18use knot_runtime::{Clock, Entropy, HttpTransport, OsEntropy}; 19use knot_types::{AccountDid, ActorId, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, KnotHostname}; 20use russh::keys::ssh_key::rand_core; 21use russh::keys::{Algorithm, PrivateKey, ssh_key}; 22use russh::server::{Config, Server as _}; 23use russh::{MethodKind, MethodSet}; 24use tokio::sync::Semaphore; 25use tokio_util::sync::CancellationToken; 26use tokio_util::task::TaskTracker; 27 28use knot_resource::{LimitConfig, PerPeerInflight, PreAuthLimiter, Slots}; 29use roster::KeyRoster; 30use server::KnotSshServer; 31 32const MAX_INFLIGHT_PER_PEER: usize = 4; 33const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(120); 34const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); 35const AUTH_REJECTION_TIME: Duration = Duration::from_millis(250); 36const DRAIN_GRACE: Duration = Duration::from_secs(30); 37const ACCEPT_BACKOFF: Duration = Duration::from_millis(250); 38 39#[derive(Debug, thiserror::Error)] 40pub enum SshError { 41 #[error("ssh host key {path}: {message}")] 42 HostKey { path: String, message: String }, 43 #[error("ssh server bind or serve: {0}")] 44 Serve(#[from] std::io::Error), 45} 46 47pub struct SshState<H, C> { 48 layout: Layout, 49 index: Arc<Index>, 50 atproto: Arc<Atproto<H, C>>, 51 knot_actor: ActorId, 52 events: Arc<EventLog<C>>, 53 hostname: KnotHostname, 54 appview: AppviewEndpoint, 55 admins: BTreeSet<AccountDid>, 56 admission: AdmissionPolicy, 57 limits: PackLimits, 58 max_pack_bytes: MaxWireBytes, 59 languages_push_budget: LanguagesPushBudget, 60 ci_logs: Option<CiLogsAddr>, 61 slots: Slots, 62 peer_slots: Arc<PreAuthLimiter>, 63 roster: Arc<KeyRoster>, 64 maintenance: MaintenanceHandle, 65 lfs: Option<LfsRuntime>, 66 catalog: Arc<knot_messages::Catalog>, 67} 68 69#[derive(Clone)] 70pub(crate) struct LfsRuntime { 71 pub(crate) handle: knot_lfs::LfsHandle, 72 pub(crate) slots: Arc<Semaphore>, 73 pub(crate) peer_slots: Arc<PreAuthLimiter>, 74} 75 76impl<H: HttpTransport, C: Clock> SshState<H, C> { 77 #[allow(clippy::too_many_arguments)] 78 pub fn new( 79 layout: Layout, 80 index: Arc<Index>, 81 atproto: Arc<Atproto<H, C>>, 82 knot_actor: ActorId, 83 events: Arc<EventLog<C>>, 84 hostname: KnotHostname, 85 appview: AppviewEndpoint, 86 admins: BTreeSet<AccountDid>, 87 admission: AdmissionPolicy, 88 max_pack_bytes: MaxWireBytes, 89 languages_push_budget: LanguagesPushBudget, 90 ci_logs: Option<CiLogsAddr>, 91 ) -> Self { 92 Self { 93 layout, 94 index, 95 atproto, 96 knot_actor, 97 events, 98 hostname, 99 appview, 100 admins, 101 admission, 102 limits: PackLimits::default(), 103 max_pack_bytes, 104 languages_push_budget, 105 ci_logs, 106 slots: Slots::for_machine(), 107 peer_slots: Arc::new(PreAuthLimiter::with_config(LimitConfig::per_peer_only( 108 PerPeerInflight::new(MAX_INFLIGHT_PER_PEER), 109 ))), 110 roster: Arc::new(KeyRoster::new()), 111 maintenance: MaintenanceHandle::disabled(), 112 lfs: None, 113 catalog: Arc::new(knot_messages::Catalog::defaults()), 114 } 115 } 116 117 pub fn with_catalog(mut self, catalog: Arc<knot_messages::Catalog>) -> Self { 118 self.catalog = catalog; 119 self 120 } 121 122 pub fn with_slots(mut self, slots: Slots) -> Self { 123 self.slots = slots; 124 self 125 } 126 127 pub fn with_maintenance(mut self, maintenance: MaintenanceHandle) -> Self { 128 self.maintenance = maintenance; 129 self 130 } 131 132 pub fn with_lfs(mut self, handle: knot_lfs::LfsHandle, max_transfers: usize) -> Self { 133 self.lfs = Some(LfsRuntime { 134 handle, 135 slots: Arc::new(Semaphore::new(max_transfers)), 136 peer_slots: Arc::new(PreAuthLimiter::with_config(LimitConfig::per_peer_only( 137 PerPeerInflight::new(max_transfers), 138 ))), 139 }); 140 self 141 } 142 143 pub fn with_limits(mut self, limits: PackLimits) -> Self { 144 self.limits = limits; 145 self 146 } 147} 148 149fn server_config(host_key: PrivateKey) -> Arc<Config> { 150 Arc::new(Config { 151 keys: vec![host_key], 152 methods: MethodSet::from(&[MethodKind::PublicKey][..]), 153 inactivity_timeout: Some(INACTIVITY_TIMEOUT), 154 keepalive_interval: Some(KEEPALIVE_INTERVAL), 155 auth_rejection_time: AUTH_REJECTION_TIME, 156 ..Config::default() 157 }) 158} 159 160pub async fn serve<H: HttpTransport, C: Clock>( 161 addr: SocketAddr, 162 host_key: PrivateKey, 163 state: Arc<SshState<H, C>>, 164 shutdown: CancellationToken, 165) -> Result<(), SshError> { 166 let listener = tokio::net::TcpListener::bind(addr).await?; 167 serve_drained(listener, host_key, state, shutdown).await 168} 169 170pub async fn serve_on_socket<H: HttpTransport, C: Clock>( 171 listener: tokio::net::TcpListener, 172 host_key: PrivateKey, 173 state: Arc<SshState<H, C>>, 174) -> Result<(), SshError> { 175 serve_drained(listener, host_key, state, CancellationToken::new()).await 176} 177 178#[doc(hidden)] 179pub async fn serve_drained<H: HttpTransport, C: Clock>( 180 listener: tokio::net::TcpListener, 181 host_key: PrivateKey, 182 state: Arc<SshState<H, C>>, 183 shutdown: CancellationToken, 184) -> Result<(), SshError> { 185 let config = server_config(host_key); 186 let tracker = TaskTracker::new(); 187 state.roster.prime(&state.index, &state.atproto); 188 let mut server = KnotSshServer { 189 state, 190 tracker: tracker.clone(), 191 }; 192 loop { 193 let accepted = tokio::select! { 194 () = shutdown.cancelled() => break, 195 accepted = listener.accept() => accepted, 196 }; 197 let (stream, peer) = match accepted { 198 Ok(pair) => pair, 199 Err(error) if is_connection_error(&error) => continue, 200 Err(error) => { 201 tracing::warn!("ssh accept failed, backing off: {error}"); 202 tokio::select! { 203 () = shutdown.cancelled() => break, 204 () = tokio::time::sleep(ACCEPT_BACKOFF) => {} 205 } 206 continue; 207 } 208 }; 209 let handler = server.new_client(Some(peer)); 210 let config = Arc::clone(&config); 211 tracker.spawn(async move { 212 if let Ok(session) = russh::server::run_stream(config, stream, handler).await { 213 let _ = session.await; 214 } 215 }); 216 } 217 tracker.close(); 218 let _ = tokio::time::timeout(DRAIN_GRACE, tracker.wait()).await; 219 Ok(()) 220} 221 222fn is_connection_error(error: &std::io::Error) -> bool { 223 matches!( 224 error.kind(), 225 std::io::ErrorKind::ConnectionRefused 226 | std::io::ErrorKind::ConnectionAborted 227 | std::io::ErrorKind::ConnectionReset 228 ) 229} 230 231pub fn load_or_create_host_key(path: &Path) -> Result<PrivateKey, SshError> { 232 let report = |message: String| SshError::HostKey { 233 path: path.display().to_string(), 234 message, 235 }; 236 let load = || { 237 ensure_secure_perms(path).map_err(&report)?; 238 russh::keys::load_secret_key(path, None).map_err(|error| report(error.to_string())) 239 }; 240 if path.exists() { 241 return load(); 242 } 243 let key = PrivateKey::random(&mut EntropyRng, Algorithm::Ed25519) 244 .map_err(|error| report(error.to_string()))?; 245 match persist_host_key(path, &key)? { 246 Claim::Won => Ok(key), 247 Claim::Lost => load(), 248 } 249} 250 251enum Claim { 252 Won, 253 Lost, 254} 255 256fn persist_host_key(path: &Path, key: &PrivateKey) -> Result<Claim, SshError> { 257 let report = |message: String| SshError::HostKey { 258 path: path.display().to_string(), 259 message, 260 }; 261 let pem = key 262 .to_openssh(ssh_key::LineEnding::LF) 263 .map_err(|error| report(error.to_string()))?; 264 if let Some(parent) = path.parent() { 265 std::fs::create_dir_all(parent).map_err(|error| report(error.to_string()))?; 266 } 267 let temp = unique_temp(path); 268 write_secret(&temp, pem.as_bytes()).map_err(|error| report(error.to_string()))?; 269 // `hard_link` instead of `rename` so that in case two knots 270 // are booting at the same time they don't get borked 271 // if one clobber's the other's key. here the loser reloads 272 // winner's key. 273 let claim = match std::fs::hard_link(&temp, path) { 274 Ok(()) => Claim::Won, 275 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Claim::Lost, 276 Err(error) => { 277 let _ = std::fs::remove_file(&temp); 278 return Err(report(error.to_string())); 279 } 280 }; 281 let _ = std::fs::remove_file(&temp); 282 if let Some(parent) = path.parent() 283 && let Ok(dir) = std::fs::File::open(parent) 284 { 285 let _ = dir.sync_all(); 286 } 287 Ok(claim) 288} 289 290#[cfg(unix)] 291fn ensure_secure_perms(path: &Path) -> Result<(), String> { 292 use std::os::unix::fs::MetadataExt; 293 let mode = std::fs::metadata(path) 294 .map_err(|error| error.to_string())? 295 .mode(); 296 if mode & 0o077 != 0 { 297 return Err(format!( 298 "private host key is group or other accessible at mode {:o}, run chmod 600 on it", 299 mode & 0o777 300 )); 301 } 302 Ok(()) 303} 304 305#[cfg(not(unix))] 306fn ensure_secure_perms(_path: &Path) -> Result<(), String> { 307 Ok(()) 308} 309 310fn unique_temp(path: &Path) -> std::path::PathBuf { 311 use std::sync::atomic::{AtomicU64, Ordering}; 312 static COUNTER: AtomicU64 = AtomicU64::new(0); 313 let nonce = COUNTER.fetch_add(1, Ordering::Relaxed); 314 let stem = path 315 .file_name() 316 .and_then(|name| name.to_str()) 317 .unwrap_or("ssh_host_key"); 318 path.with_file_name(format!(".{stem}.{}.{nonce}.tmp", std::process::id())) 319} 320 321fn write_secret(path: &Path, bytes: &[u8]) -> std::io::Result<()> { 322 use std::io::Write; 323 let mut options = std::fs::OpenOptions::new(); 324 options.write(true).create_new(true); 325 #[cfg(unix)] 326 { 327 use std::os::unix::fs::OpenOptionsExt; 328 options.mode(0o600); 329 } 330 let mut file = options.open(path)?; 331 file.write_all(bytes)?; 332 file.sync_all() 333} 334 335struct EntropyRng; 336 337impl rand_core::TryRng for EntropyRng { 338 type Error = std::convert::Infallible; 339 340 fn try_next_u32(&mut self) -> Result<u32, Self::Error> { 341 let mut bytes = [0u8; 4]; 342 OsEntropy.fill(&mut bytes); 343 Ok(u32::from_le_bytes(bytes)) 344 } 345 346 fn try_next_u64(&mut self) -> Result<u64, Self::Error> { 347 let mut bytes = [0u8; 8]; 348 OsEntropy.fill(&mut bytes); 349 Ok(u64::from_le_bytes(bytes)) 350 } 351 352 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> { 353 OsEntropy.fill(dst); 354 Ok(()) 355 } 356} 357 358impl rand_core::TryCryptoRng for EntropyRng {}