max / makenotwork
6 files changed,
+194 insertions,
-13 deletions
| @@ -7,6 +7,26 @@ | |||
| 7 | 7 | use crate::api::{MnwApiClient, UserInfo}; | |
| 8 | 8 | use crate::format; | |
| 9 | 9 | ||
| 10 | + | /// Sanitize an API error for display to SSH clients. | |
| 11 | + | /// | |
| 12 | + | /// Strips raw response bodies (HTML error pages, stack traces) that may leak | |
| 13 | + | /// from anyhow error chains. Keeps the high-level context + HTTP status code | |
| 14 | + | /// but drops everything after ` — ` (the body separator our API client uses). | |
| 15 | + | pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String { | |
| 16 | + | let msg = e.to_string(); | |
| 17 | + | // Our API client formats errors as "context: HTTP 500 — <body>". | |
| 18 | + | // Keep the part before the body separator. | |
| 19 | + | if let Some(idx) = msg.find(" \u{2014} ") { | |
| 20 | + | return msg[..idx].to_string(); | |
| 21 | + | } | |
| 22 | + | // For connection/timeout errors, return a generic message. | |
| 23 | + | if msg.contains("connect") || msg.contains("timed out") || msg.contains("dns") { | |
| 24 | + | return "Service temporarily unavailable".to_string(); | |
| 25 | + | } | |
| 26 | + | // Fallback: return the first line only (avoid multi-line leaks). | |
| 27 | + | msg.lines().next().unwrap_or("Unknown error").to_string() | |
| 28 | + | } | |
| 29 | + | ||
| 10 | 30 | /// Execute a non-interactive command and return the output bytes. | |
| 11 | 31 | pub async fn execute( | |
| 12 | 32 | command_line: &str, | |
| @@ -83,7 +103,7 @@ async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8 | |||
| 83 | 103 | } | |
| 84 | 104 | out.into_bytes() | |
| 85 | 105 | } | |
| 86 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 106 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 87 | 107 | } | |
| 88 | 108 | } | |
| 89 | 109 | ||
| @@ -127,7 +147,7 @@ async fn cmd_analytics( | |||
| 127 | 147 | ||
| 128 | 148 | out.into_bytes() | |
| 129 | 149 | } | |
| 130 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 150 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 131 | 151 | } | |
| 132 | 152 | } | |
| 133 | 153 | ||
| @@ -160,14 +180,14 @@ async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Ve | |||
| 160 | 180 | } | |
| 161 | 181 | out.into_bytes() | |
| 162 | 182 | } | |
| 163 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 183 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 164 | 184 | } | |
| 165 | 185 | } | |
| 166 | 186 | ||
| 167 | 187 | async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 168 | 188 | match api.export_sales_csv(&user.user_id).await { | |
| 169 | 189 | Ok(result) => result.csv.into_bytes(), | |
| 170 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 190 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 171 | 191 | } | |
| 172 | 192 | } | |
| 173 | 193 | ||
| @@ -211,7 +231,7 @@ async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec< | |||
| 211 | 231 | } | |
| 212 | 232 | out.into_bytes() | |
| 213 | 233 | } | |
| 214 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 234 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 215 | 235 | } | |
| 216 | 236 | } | |
| 217 | 237 | ||
| @@ -230,7 +250,7 @@ async fn cmd_promo_create( | |||
| 230 | 250 | .await | |
| 231 | 251 | { | |
| 232 | 252 | Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(), | |
| 233 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 253 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 234 | 254 | } | |
| 235 | 255 | } | |
| 236 | 256 | ||
| @@ -247,7 +267,7 @@ async fn cmd_blog_list( | |||
| 247 | 267 | // Find the project by slug | |
| 248 | 268 | let projects = match api.get_projects(&user.user_id).await { | |
| 249 | 269 | Ok(p) => p, | |
| 250 | - | Err(e) => return format!("Error: {e}\r\n").into_bytes(), | |
| 270 | + | Err(e) => return format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 251 | 271 | }; | |
| 252 | 272 | ||
| 253 | 273 | let Some(project) = projects.iter().find(|p| p.slug == slug) else { | |
| @@ -281,7 +301,7 @@ async fn cmd_blog_list( | |||
| 281 | 301 | } | |
| 282 | 302 | out.into_bytes() | |
| 283 | 303 | } | |
| 284 | - | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 304 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 285 | 305 | } | |
| 286 | 306 | } | |
| 287 | 307 |
| @@ -8,6 +8,7 @@ mod api; | |||
| 8 | 8 | mod commands; | |
| 9 | 9 | mod config; | |
| 10 | 10 | mod format; | |
| 11 | + | mod rate_limit; | |
| 11 | 12 | mod ssh; | |
| 12 | 13 | mod staging; | |
| 13 | 14 | mod tui; | |
| @@ -66,7 +67,8 @@ async fn main() -> anyhow::Result<()> { | |||
| 66 | 67 | ||
| 67 | 68 | let staging_dir = Arc::new(config.staging_dir); | |
| 68 | 69 | let api_client = api::MnwApiClient::new(config.api_url, config.service_token); | |
| 69 | - | let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user); | |
| 70 | + | let rate_limiter = Arc::new(rate_limit::AuthRateLimiter::new()); | |
| 71 | + | let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user, rate_limiter); | |
| 70 | 72 | ||
| 71 | 73 | let addr = format!("0.0.0.0:{}", config.port); | |
| 72 | 74 | tracing::info!(%addr, "listening for SSH connections"); |
| @@ -0,0 +1,98 @@ | |||
| 1 | + | //! Per-IP authentication rate limiting. | |
| 2 | + | //! | |
| 3 | + | //! russh's `auth_rejection_time` only delays within a single connection. | |
| 4 | + | //! Parallel connections bypass it. This module tracks failed auth attempts | |
| 5 | + | //! per IP and rejects early when a threshold is exceeded. | |
| 6 | + | ||
| 7 | + | use std::collections::HashMap; | |
| 8 | + | use std::net::IpAddr; | |
| 9 | + | use std::sync::Mutex; | |
| 10 | + | use std::time::Instant; | |
| 11 | + | ||
| 12 | + | const MAX_FAILURES: usize = 10; | |
| 13 | + | const WINDOW_SECS: u64 = 60; | |
| 14 | + | const PRUNE_THRESHOLD: usize = 1000; | |
| 15 | + | ||
| 16 | + | pub struct AuthRateLimiter { | |
| 17 | + | failures: Mutex<HashMap<IpAddr, Vec<Instant>>>, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | impl AuthRateLimiter { | |
| 21 | + | pub fn new() -> Self { | |
| 22 | + | Self { | |
| 23 | + | failures: Mutex::new(HashMap::new()), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Returns `true` if the IP is allowed to attempt auth. | |
| 28 | + | /// Returns `false` if the IP has exceeded the failure threshold. | |
| 29 | + | pub fn check(&self, ip: IpAddr) -> bool { | |
| 30 | + | let mut map = self.failures.lock().unwrap(); | |
| 31 | + | let cutoff = Instant::now() - std::time::Duration::from_secs(WINDOW_SECS); | |
| 32 | + | ||
| 33 | + | if let Some(times) = map.get_mut(&ip) { | |
| 34 | + | times.retain(|t| *t > cutoff); | |
| 35 | + | times.len() < MAX_FAILURES | |
| 36 | + | } else { | |
| 37 | + | true | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Record a failed auth attempt for the given IP. | |
| 42 | + | pub fn record_failure(&self, ip: IpAddr) { | |
| 43 | + | let mut map = self.failures.lock().unwrap(); | |
| 44 | + | ||
| 45 | + | // Prune stale entries when map grows large | |
| 46 | + | if map.len() > PRUNE_THRESHOLD { | |
| 47 | + | let cutoff = Instant::now() - std::time::Duration::from_secs(WINDOW_SECS); | |
| 48 | + | map.retain(|_, times| { | |
| 49 | + | times.retain(|t| *t > cutoff); | |
| 50 | + | !times.is_empty() | |
| 51 | + | }); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | map.entry(ip).or_default().push(Instant::now()); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[cfg(test)] | |
| 59 | + | mod tests { | |
| 60 | + | use super::*; | |
| 61 | + | use std::net::Ipv4Addr; | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn allows_under_threshold() { | |
| 65 | + | let limiter = AuthRateLimiter::new(); | |
| 66 | + | let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); | |
| 67 | + | ||
| 68 | + | for _ in 0..MAX_FAILURES - 1 { | |
| 69 | + | assert!(limiter.check(ip)); | |
| 70 | + | limiter.record_failure(ip); | |
| 71 | + | } | |
| 72 | + | assert!(limiter.check(ip)); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | #[test] | |
| 76 | + | fn blocks_at_threshold() { | |
| 77 | + | let limiter = AuthRateLimiter::new(); | |
| 78 | + | let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); | |
| 79 | + | ||
| 80 | + | for _ in 0..MAX_FAILURES { | |
| 81 | + | limiter.record_failure(ip); | |
| 82 | + | } | |
| 83 | + | assert!(!limiter.check(ip)); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[test] | |
| 87 | + | fn independent_ips() { | |
| 88 | + | let limiter = AuthRateLimiter::new(); | |
| 89 | + | let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); | |
| 90 | + | let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); | |
| 91 | + | ||
| 92 | + | for _ in 0..MAX_FAILURES { | |
| 93 | + | limiter.record_failure(ip_a); | |
| 94 | + | } | |
| 95 | + | assert!(!limiter.check(ip_a)); | |
| 96 | + | assert!(limiter.check(ip_b)); | |
| 97 | + | } | |
| 98 | + | } |
| @@ -11,6 +11,7 @@ use russh::{Channel, ChannelId}; | |||
| 11 | 11 | use tokio::sync::Mutex; | |
| 12 | 12 | ||
| 13 | 13 | use crate::api::{MnwApiClient, UserInfo}; | |
| 14 | + | use crate::rate_limit::AuthRateLimiter; | |
| 14 | 15 | use crate::ssh::git; | |
| 15 | 16 | use crate::ssh::sftp::SftpSession; | |
| 16 | 17 | use crate::ssh::terminal::TerminalHandle; | |
| @@ -23,6 +24,7 @@ pub struct MnwHandler { | |||
| 23 | 24 | peer_addr: Option<SocketAddr>, | |
| 24 | 25 | staging_dir: Arc<PathBuf>, | |
| 25 | 26 | git_user: Arc<str>, | |
| 27 | + | rate_limiter: Arc<AuthRateLimiter>, | |
| 26 | 28 | /// Populated after successful auth. | |
| 27 | 29 | user: Option<UserInfo>, | |
| 28 | 30 | /// Terminal dimensions (cols, rows). | |
| @@ -41,12 +43,14 @@ impl MnwHandler { | |||
| 41 | 43 | peer_addr: Option<SocketAddr>, | |
| 42 | 44 | staging_dir: Arc<PathBuf>, | |
| 43 | 45 | git_user: Arc<str>, | |
| 46 | + | rate_limiter: Arc<AuthRateLimiter>, | |
| 44 | 47 | ) -> Self { | |
| 45 | 48 | Self { | |
| 46 | 49 | api, | |
| 47 | 50 | peer_addr, | |
| 48 | 51 | staging_dir, | |
| 49 | 52 | git_user, | |
| 53 | + | rate_limiter, | |
| 50 | 54 | user: None, | |
| 51 | 55 | term_size: (80, 24), | |
| 52 | 56 | app: None, | |
| @@ -64,6 +68,17 @@ impl russh::server::Handler for MnwHandler { | |||
| 64 | 68 | _user: &str, | |
| 65 | 69 | key: &PublicKey, | |
| 66 | 70 | ) -> Result<Auth, Self::Error> { | |
| 71 | + | // Per-IP rate limiting: reject early if threshold exceeded | |
| 72 | + | if let Some(addr) = self.peer_addr { | |
| 73 | + | if !self.rate_limiter.check(addr.ip()) { | |
| 74 | + | tracing::warn!(peer = %addr, "auth rate limit exceeded"); | |
| 75 | + | return Ok(Auth::Reject { | |
| 76 | + | proceed_with_methods: None, | |
| 77 | + | partial_success: false, | |
| 78 | + | }); | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 67 | 82 | let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 68 | 83 | tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered"); | |
| 69 | 84 | ||
| @@ -71,6 +86,9 @@ impl russh::server::Handler for MnwHandler { | |||
| 71 | 86 | Ok(Some(info)) => { | |
| 72 | 87 | if info.suspended { | |
| 73 | 88 | tracing::warn!(user = %info.username, "suspended user attempted SSH login"); | |
| 89 | + | if let Some(addr) = self.peer_addr { | |
| 90 | + | self.rate_limiter.record_failure(addr.ip()); | |
| 91 | + | } | |
| 74 | 92 | return Ok(Auth::Reject { | |
| 75 | 93 | proceed_with_methods: None, | |
| 76 | 94 | partial_success: false, | |
| @@ -81,6 +99,9 @@ impl russh::server::Handler for MnwHandler { | |||
| 81 | 99 | } | |
| 82 | 100 | Ok(None) => { | |
| 83 | 101 | tracing::debug!(%fingerprint, "key not found"); | |
| 102 | + | if let Some(addr) = self.peer_addr { | |
| 103 | + | self.rate_limiter.record_failure(addr.ip()); | |
| 104 | + | } | |
| 84 | 105 | Ok(Auth::Reject { | |
| 85 | 106 | proceed_with_methods: None, | |
| 86 | 107 | partial_success: false, | |
| @@ -280,8 +301,10 @@ impl russh::server::Handler for MnwHandler { | |||
| 280 | 301 | } | |
| 281 | 302 | } | |
| 282 | 303 | Err(e) => { | |
| 283 | - | let msg = | |
| 284 | - | bytes::Bytes::from(format!("fatal: {e}\r\n")); | |
| 304 | + | let msg = bytes::Bytes::from(format!( | |
| 305 | + | "fatal: {}\r\n", | |
| 306 | + | crate::commands::sanitize_api_error(&e) | |
| 307 | + | )); | |
| 285 | 308 | let _ = handle.extended_data(channel, 1, msg).await; | |
| 286 | 309 | let _ = handle.exit_status_request(channel, 1).await; | |
| 287 | 310 | let _ = handle.eof(channel).await; |
| @@ -10,20 +10,28 @@ use std::path::PathBuf; | |||
| 10 | 10 | use std::sync::Arc; | |
| 11 | 11 | ||
| 12 | 12 | use crate::api::MnwApiClient; | |
| 13 | + | use crate::rate_limit::AuthRateLimiter; | |
| 13 | 14 | ||
| 14 | 15 | /// SSH server that spawns a new handler per connection. | |
| 15 | 16 | pub struct MnwServer { | |
| 16 | 17 | api: MnwApiClient, | |
| 17 | 18 | staging_dir: Arc<PathBuf>, | |
| 18 | 19 | git_user: Arc<str>, | |
| 20 | + | rate_limiter: Arc<AuthRateLimiter>, | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | impl MnwServer { | |
| 22 | - | pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>, git_user: String) -> Self { | |
| 24 | + | pub fn new( | |
| 25 | + | api: MnwApiClient, | |
| 26 | + | staging_dir: Arc<PathBuf>, | |
| 27 | + | git_user: String, | |
| 28 | + | rate_limiter: Arc<AuthRateLimiter>, | |
| 29 | + | ) -> Self { | |
| 23 | 30 | Self { | |
| 24 | 31 | api, | |
| 25 | 32 | staging_dir, | |
| 26 | 33 | git_user: Arc::from(git_user), | |
| 34 | + | rate_limiter, | |
| 27 | 35 | } | |
| 28 | 36 | } | |
| 29 | 37 | } | |
| @@ -38,6 +46,7 @@ impl russh::server::Server for MnwServer { | |||
| 38 | 46 | peer_addr, | |
| 39 | 47 | Arc::clone(&self.staging_dir), | |
| 40 | 48 | Arc::clone(&self.git_user), | |
| 49 | + | Arc::clone(&self.rate_limiter), | |
| 41 | 50 | ) | |
| 42 | 51 | } | |
| 43 | 52 | } |
| @@ -642,7 +642,14 @@ pub(super) async fn handle_item_input( | |||
| 642 | 642 | // Confirmed — execute delete | |
| 643 | 643 | app.confirm_action = None; | |
| 644 | 644 | let item_id = detail.id.clone(); | |
| 645 | + | let item_title = detail.title.clone(); | |
| 645 | 646 | let user_id = app.user.user_id.clone(); | |
| 647 | + | tracing::info!( | |
| 648 | + | user_id = %user_id, | |
| 649 | + | item_id = %item_id, | |
| 650 | + | item_title = %item_title, | |
| 651 | + | "delete item confirmed" | |
| 652 | + | ); | |
| 646 | 653 | let api = api.clone(); | |
| 647 | 654 | let tx = tx.clone(); | |
| 648 | 655 | app.item_status = Some("Deleting...".to_string()); | |
| @@ -818,7 +825,14 @@ pub(super) async fn handle_blog_input( | |||
| 818 | 825 | // Confirmed — execute delete | |
| 819 | 826 | app.confirm_action = None; | |
| 820 | 827 | let post_id = app.blog_posts[idx].id.clone(); | |
| 828 | + | let post_title = app.blog_posts[idx].title.clone(); | |
| 821 | 829 | let user_id = app.user.user_id.clone(); | |
| 830 | + | tracing::info!( | |
| 831 | + | user_id = %user_id, | |
| 832 | + | post_id = %post_id, | |
| 833 | + | post_title = %post_title, | |
| 834 | + | "delete blog post confirmed" | |
| 835 | + | ); | |
| 822 | 836 | let api = api.clone(); | |
| 823 | 837 | let tx = tx.clone(); | |
| 824 | 838 | ||
| @@ -971,8 +985,15 @@ pub(super) async fn handle_promo_input( | |||
| 971 | 985 | // Confirmed — execute delete | |
| 972 | 986 | app.confirm_action = None; | |
| 973 | 987 | let code_id = app.promo_codes[idx].id.clone(); | |
| 974 | - | let api = api.clone(); | |
| 988 | + | let code_value = app.promo_codes[idx].code.clone(); | |
| 975 | 989 | let user_id = app.user.user_id.clone(); | |
| 990 | + | tracing::info!( | |
| 991 | + | user_id = %user_id, | |
| 992 | + | code_id = %code_id, | |
| 993 | + | code = %code_value, | |
| 994 | + | "delete promo code confirmed" | |
| 995 | + | ); | |
| 996 | + | let api = api.clone(); | |
| 976 | 997 | let tx = tx.clone(); | |
| 977 | 998 | app.promo_status = Some("Deleting...".to_string()); | |
| 978 | 999 | tokio::spawn(async move { | |
| @@ -1082,6 +1103,14 @@ pub(super) async fn handle_keys_input( | |||
| 1082 | 1103 | // Confirmed — execute revoke | |
| 1083 | 1104 | app.confirm_action = None; | |
| 1084 | 1105 | let key_id = app.license_keys[idx].id.clone(); | |
| 1106 | + | let key_code = app.license_keys[idx].key_code.clone(); | |
| 1107 | + | let user_id = app.user.user_id.clone(); | |
| 1108 | + | tracing::info!( | |
| 1109 | + | user_id = %user_id, | |
| 1110 | + | key_id = %key_id, | |
| 1111 | + | key_code = %key_code, | |
| 1112 | + | "revoke license key confirmed" | |
| 1113 | + | ); | |
| 1085 | 1114 | if let Screen::Keys(_, item_id) = &*screen { | |
| 1086 | 1115 | let item_id = item_id.clone(); | |
| 1087 | 1116 | let api = api.clone(); |