use crate::handlers::shared::{ decrypt_or_migrate_system_config_secret, system_config_bool, system_config_string, }; use crate::{AppState, GatewayError}; use serde_json::Value; use std::net::SocketAddr; use std::time::Duration; pub(crate) const SERVER_CHAN_PUSH_ENABLED_KEY: &str = "module.server_chan_push.enabled"; pub(crate) const SERVER_CHAN_PUSH_SEND_KEY_KEY: &str = "module.server_chan_push.send_key"; pub(crate) const SERVER_CHAN_PUSH_TEMPLATE_KEY: &str = "module.server_chan_push.template"; pub(crate) const LEGACY_SERVER_CHAN_ENABLED_KEY: &str = "module.important_notification.server_chan_enabled"; pub(crate) const LEGACY_SERVER_CHAN_SEND_KEY_KEY: &str = "module.important_notification.server_chan_send_key"; pub(crate) const LEGACY_SERVER_CHAN_TEMPLATE_KEY: &str = "module.important_notification.server_chan_template"; const SERVER_CHAN_API_BASE: &str = "https://sctapi.ftqq.com"; const SERVER_CHAN_API_HOST: &str = "sctapi.ftqq.com"; const SERVER_CHAN_API_PORT: u16 = 443; const SERVER_CHAN_DNS_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PUSH_RESPONSE_BYTES: usize = 64 * 1024; const MAX_SERVER_CHAN_SEND_KEY_BYTES: usize = 512; const MAX_SERVER_CHAN_TEMPLATE_BYTES: usize = 256 * 1024; const MAX_SERVER_CHAN_TITLE_BYTES: usize = 512; const MAX_SERVER_CHAN_BODY_BYTES: usize = 2 * 1024 * 1024; const MAX_SERVER_CHAN_RENDERED_BODY_BYTES: usize = 2 * 1024 * 1024; #[cfg(test)] fn build_server_chan_client() -> Result { reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(300)) .build() } fn validate_server_chan_resolved_addresses( addresses: &[SocketAddr], allow_benchmarking_ip: bool, ) -> Result<(), &'static str> { if addresses.is_empty() { return Err("Server Chan API DNS resolution returned no addresses"); } if addresses.iter().any(|address| { aether_http::is_private_or_reserved_ip(address.ip()) && !(allow_benchmarking_ip && aether_http::is_ipv4_benchmarking_fake_ip(address.ip())) }) { return Err("Server Chan API resolved to a private or reserved address"); } Ok(()) } /// Build the production client with the API hostname pinned to one validated /// DNS answer. The SendKey is carried in the request path; allowing reqwest /// to resolve the host again at connect time would let a DNS rebinding or a /// poisoned resolver redirect that credential to an unintended destination. async fn build_pinned_server_chan_client() -> Result { let addresses = aether_http::lookup_host_with_limits( SERVER_CHAN_API_HOST, SERVER_CHAN_API_PORT, SERVER_CHAN_DNS_TIMEOUT, ) .await .map_err(|error| match error.kind() { std::io::ErrorKind::TimedOut => "Server Chan API DNS resolution timed out", _ => "Server Chan API DNS resolution failed", })?; validate_server_chan_resolved_addresses(&addresses, true)?; reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(SERVER_CHAN_DNS_TIMEOUT) .timeout(Duration::from_secs(300)) .resolve_to_addrs(SERVER_CHAN_API_HOST, &addresses) .build() .map_err(|_| "Server Chan HTTP client initialization failed") } #[derive(Clone)] pub(crate) struct ServerChanPushConfig { pub(crate) enabled: bool, pub(crate) send_key: Option, pub(crate) template: Option, } impl std::fmt::Debug for ServerChanPushConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("ServerChanPushConfig") .field("enabled", &self.enabled) .field("send_key", &self.send_key.as_ref().map(|_| "[REDACTED]")) .field("template", &self.template.as_ref().map(|_| "[REDACTED]")) .finish() } } pub(crate) async fn server_chan_push_module_enabled( state: &AppState, ) -> Result { let canonical = state .read_system_config_json_value(SERVER_CHAN_PUSH_ENABLED_KEY) .await?; if canonical.is_some() { return Ok(system_config_bool(canonical.as_ref(), false)); } let legacy = state .read_system_config_json_value(LEGACY_SERVER_CHAN_ENABLED_KEY) .await?; Ok(system_config_bool(legacy.as_ref(), false)) } pub(crate) async fn server_chan_push_configured(state: &AppState) -> Result { Ok(read_server_chan_push_config(state) .await? .send_key .is_some()) } pub(crate) async fn read_server_chan_push_config( state: &AppState, ) -> Result { let enabled = server_chan_push_module_enabled(state).await?; let send_key = read_server_chan_secret(state).await?; let template = read_server_chan_value( state, SERVER_CHAN_PUSH_TEMPLATE_KEY, LEGACY_SERVER_CHAN_TEMPLATE_KEY, ) .await? .and_then(|value| system_config_string(Some(&value))); if let Some(template) = template.as_deref() { validate_server_chan_field("template", template, MAX_SERVER_CHAN_TEMPLATE_BYTES)?; } Ok(ServerChanPushConfig { enabled, send_key, template, }) } async fn read_server_chan_secret(state: &AppState) -> Result, GatewayError> { for key in [ SERVER_CHAN_PUSH_SEND_KEY_KEY, LEGACY_SERVER_CHAN_SEND_KEY_KEY, ] { let Some(value) = state.read_system_config_json_value(key).await? else { continue; }; let Some(value) = system_config_string(Some(&value)) else { return Ok(None); }; let value = decrypt_or_migrate_system_config_secret(state, key, value).await?; validate_server_chan_field("send_key", &value, MAX_SERVER_CHAN_SEND_KEY_BYTES)?; return Ok(Some(value)); } Ok(None) } async fn read_server_chan_value( state: &AppState, canonical_key: &str, legacy_key: &str, ) -> Result, GatewayError> { let canonical = state.read_system_config_json_value(canonical_key).await?; if canonical.is_some() { return Ok(canonical); } state.read_system_config_json_value(legacy_key).await } pub(crate) async fn send_server_chan_push( _state: &AppState, config: &ServerChanPushConfig, title: &str, markdown_body: &str, ) -> Result<(), GatewayError> { let Some(send_key) = config.send_key.as_deref() else { return Err(GatewayError::Internal( "未配置 Server 酱 SendKey".to_string(), )); }; let send_key = send_key.trim(); if send_key.is_empty() { return Err(GatewayError::Internal( "Server 酱 SendKey 不能为空".to_string(), )); } if !is_safe_server_chan_send_key(send_key) { return Err(GatewayError::Internal( "Server 酱 SendKey 格式无效".to_string(), )); } validate_server_chan_field("send_key", send_key, MAX_SERVER_CHAN_SEND_KEY_BYTES)?; validate_server_chan_field("title", title, MAX_SERVER_CHAN_TITLE_BYTES)?; validate_server_chan_field("body", markdown_body, MAX_SERVER_CHAN_BODY_BYTES)?; let desp = render_server_chan_desp(config.template.as_deref(), title, markdown_body)?; let url = format!("{SERVER_CHAN_API_BASE}/{send_key}.send"); let client = build_pinned_server_chan_client() .await .map_err(|message| GatewayError::Internal(message.to_string()))?; let response = client .post(url) .form(&[("title", title), ("desp", desp.as_str())]) .send() .await .map_err(|err| GatewayError::Internal(server_chan_request_error_message(&err)))?; let status = response.status(); let body = aether_http::read_response_bytes_with_limit(response, MAX_PUSH_RESPONSE_BYTES) .await .map_err(|err| GatewayError::Internal(server_chan_response_body_error_message(&err)))?; let text = String::from_utf8_lossy(&body); if let Some(message) = server_chan_response_failure_message(status, &text) { return Err(GatewayError::Internal(message)); } Ok(()) } fn is_safe_server_chan_send_key(send_key: &str) -> bool { !send_key.is_empty() && send_key.len() <= MAX_SERVER_CHAN_SEND_KEY_BYTES && send_key .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } fn server_chan_response_failure_message(status: http::StatusCode, text: &str) -> Option { if !status.is_success() { return Some(format!("Server Chan returned HTTP {status}")); } let payload = serde_json::from_str::(text).ok()?; let code_is_ok = payload .get("code") .and_then(|value| { value .as_i64() .map(|code| code == 0) .or_else(|| value.as_str().map(|code| code.trim() == "0")) }) .unwrap_or(true); (!code_is_ok).then(|| "Server Chan returned failure".to_string()) } fn server_chan_request_error_message(error: &reqwest::Error) -> String { // Reqwest errors may include the request URL. ServerChan authenticates with // the SendKey in that URL's path, so forwarding the source error would leak // the credential into logs and notification test responses. format!("Server Chan request failed ({})", reqwest_error_kind(error)) } fn server_chan_response_body_error_message(error: &aether_http::ResponseBodyReadError) -> String { match error { aether_http::ResponseBodyReadError::TooLarge { max_bytes } => { format!("Server Chan response exceeds {max_bytes} bytes") } aether_http::ResponseBodyReadError::Read(error) => format!( "Server Chan response read failed ({})", reqwest_error_kind(error) ), } } fn reqwest_error_kind(error: &reqwest::Error) -> &'static str { if error.is_timeout() { "timeout" } else if error.is_connect() { "connect" } else if error.is_request() { "request" } else { "transport" } } fn validate_server_chan_field( field: &str, value: &str, max_bytes: usize, ) -> Result<(), GatewayError> { if value.len() > max_bytes || value.bytes().any(|byte| byte == 0) { return Err(GatewayError::Internal(format!( "Server Chan {field} exceeds the allowed size or contains a NUL byte" ))); } Ok(()) } fn render_server_chan_desp( template: Option<&str>, title: &str, markdown_body: &str, ) -> Result { validate_server_chan_field("title", title, MAX_SERVER_CHAN_TITLE_BYTES)?; validate_server_chan_field("body", markdown_body, MAX_SERVER_CHAN_BODY_BYTES)?; let template = template .filter(|value| !value.trim().is_empty()) .unwrap_or("{body}"); validate_server_chan_field("template", template, MAX_SERVER_CHAN_TEMPLATE_BYTES)?; let mut rendered = String::with_capacity( template .len() .saturating_add(title.len()) .saturating_add(markdown_body.len()) .min(MAX_SERVER_CHAN_RENDERED_BODY_BYTES), ); let mut cursor = 0usize; while cursor < template.len() { let remaining = &template[cursor..]; let title_match = remaining.find("{title}"); let body_match = remaining.find("{body}"); let next = match (title_match, body_match) { (None, None) => { append_server_chan_rendered_part(&mut rendered, remaining)?; cursor = template.len(); continue; } (Some(index), None) => (index, "{title}", title), (None, Some(index)) => (index, "{body}", markdown_body), (Some(title_index), Some(body_index)) if title_index <= body_index => { (title_index, "{title}", title) } (Some(_), Some(body_index)) => (body_index, "{body}", markdown_body), }; append_server_chan_rendered_part(&mut rendered, &remaining[..next.0])?; append_server_chan_rendered_part(&mut rendered, next.2)?; cursor += next.0 + next.1.len(); } Ok(rendered) } fn append_server_chan_rendered_part(output: &mut String, part: &str) -> Result<(), GatewayError> { let next_len = output.len().checked_add(part.len()).ok_or_else(|| { GatewayError::Internal("Server Chan rendered body is too large".to_string()) })?; if next_len > MAX_SERVER_CHAN_RENDERED_BODY_BYTES { return Err(GatewayError::Internal( "Server Chan rendered body exceeds the allowed size".to_string(), )); } output.push_str(part); Ok(()) } #[cfg(test)] mod tests { use super::{ build_server_chan_client, is_safe_server_chan_send_key, read_server_chan_push_config, render_server_chan_desp, server_chan_request_error_message, server_chan_response_body_error_message, server_chan_response_failure_message, validate_server_chan_resolved_addresses, LEGACY_SERVER_CHAN_SEND_KEY_KEY, }; use crate::data::GatewayDataState; use crate::handlers::shared::decrypt_system_config_secret; use crate::AppState; use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY; use axum::{ http::{header, StatusCode}, response::IntoResponse, routing::post, Router, }; use std::net::SocketAddr; use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, }; #[tokio::test] async fn legacy_send_key_is_migrated_at_its_original_config_key() { let plaintext = "SCT-legacy-plaintext-send-key"; let data = GatewayDataState::disabled() .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY) .with_system_config_values_for_tests([( LEGACY_SERVER_CHAN_SEND_KEY_KEY.to_string(), serde_json::json!(plaintext), )]); let mut state = AppState::new().expect("gateway state should build"); state.replace_data_state(Arc::new(data)); let config = read_server_chan_push_config(&state) .await .expect("legacy config should read"); assert_eq!(config.send_key.as_deref(), Some(plaintext)); let stored = state .read_system_config_json_value_strong(LEGACY_SERVER_CHAN_SEND_KEY_KEY) .await .expect("migrated legacy key should read") .and_then(|value| value.as_str().map(ToOwned::to_owned)) .expect("migrated legacy key should remain a string"); assert_ne!(stored, plaintext); assert_eq!( decrypt_system_config_secret(&state, LEGACY_SERVER_CHAN_SEND_KEY_KEY, &stored) .expect("migrated legacy key should decrypt"), plaintext ); } #[tokio::test] async fn server_chan_client_never_forwards_send_key_across_redirects() { let redirected_hits = Arc::new(AtomicUsize::new(0)); let redirected_hits_for_route = Arc::clone(&redirected_hits); let redirected_listener = crate::test_support::bind_loopback_listener() .await .expect("redirect target listener"); let redirected_addr = redirected_listener .local_addr() .expect("redirect target addr"); let redirected_app = Router::new().route( "/capture", post(move || { let hits = Arc::clone(&redirected_hits_for_route); async move { hits.fetch_add(1, Ordering::SeqCst); StatusCode::OK } }), ); let redirected_server = tokio::spawn(async move { axum::serve(redirected_listener, redirected_app) .await .expect("redirect target server"); }); let source_listener = crate::test_support::bind_loopback_listener() .await .expect("redirect source listener"); let source_addr = source_listener.local_addr().expect("redirect source addr"); let location = format!("http://{redirected_addr}/capture"); let source_app = Router::new().route( "/SCT-secret-send-key.send", post(move || { let location = location.clone(); async move { (StatusCode::FOUND, [(header::LOCATION, location)]).into_response() } }), ); let source_server = tokio::spawn(async move { axum::serve(source_listener, source_app) .await .expect("redirect source server"); }); let response = build_server_chan_client() .expect("client") .post(format!("http://{source_addr}/SCT-secret-send-key.send")) .send() .await .expect("redirect response"); assert_eq!(response.status(), StatusCode::FOUND); assert_eq!(redirected_hits.load(Ordering::SeqCst), 0); source_server.abort(); redirected_server.abort(); } #[tokio::test] async fn server_chan_transport_error_does_not_expose_send_key_url() { let send_key = "SCT-secret-send-key"; let error = reqwest::Client::new() .post(format!("ftp://sctapi.ftqq.com/{send_key}.send")) .send() .await .expect_err("unsupported URL scheme should fail before network I/O"); let message = server_chan_request_error_message(&error); assert!(message.starts_with("Server Chan request failed (")); assert!(!message.contains(send_key)); assert!(!message.contains("sctapi.ftqq.com")); assert!(!message.contains(".send")); let body_error = aether_http::ResponseBodyReadError::Read(error); let message = server_chan_response_body_error_message(&body_error); assert!(message.starts_with("Server Chan response read failed (")); assert!(!message.contains(send_key)); assert!(!message.contains("sctapi.ftqq.com")); assert!(!message.contains(".send")); } #[test] fn server_chan_dns_answers_must_be_public() { assert!(validate_server_chan_resolved_addresses( &[SocketAddr::from(([1, 1, 1, 1], 443))], false, ) .is_ok()); for address in [ SocketAddr::from(([127, 0, 0, 1], 443)), SocketAddr::from(([10, 0, 0, 1], 443)), SocketAddr::from(([169, 254, 169, 254], 443)), ] { assert!( validate_server_chan_resolved_addresses(&[address], false).is_err(), "private Server Chan DNS answer should be rejected: {address}" ); } assert!(validate_server_chan_resolved_addresses(&[], false).is_err()); } #[test] fn server_chan_dns_allows_benchmarking_ip_for_builtin_host() { let fake = SocketAddr::from(([198, 18, 75, 234], 443)); assert!(validate_server_chan_resolved_addresses(&[fake], true).is_ok()); assert!(validate_server_chan_resolved_addresses( &[fake, SocketAddr::from(([127, 0, 0, 1], 443))], true, ) .is_err()); assert!(validate_server_chan_resolved_addresses(&[fake], false).is_err()); } #[test] fn server_chan_failure_response_does_not_expose_arbitrary_body() { let secret_body = "upstream echoed SCT-secret-send-key and internal details"; let message = server_chan_response_failure_message(http::StatusCode::BAD_GATEWAY, secret_body) .expect("non-success response should fail"); assert_eq!(message, "Server Chan returned HTTP 502 Bad Gateway"); assert!(!message.contains(secret_body)); assert!(!message.contains("SCT-secret-send-key")); let message = server_chan_response_failure_message( http::StatusCode::OK, r#"{"code":"SCT-secret-send-key","message":"internal details"}"#, ) .expect("non-zero business response should fail"); assert_eq!(message, "Server Chan returned failure"); } #[test] fn server_chan_send_key_cannot_escape_the_request_path() { assert!(is_safe_server_chan_send_key("SCT123abc-._")); for unsafe_key in [ "SCT/other", "SCT?token=secret", "SCT#fragment", "SCT\\other", "SCT key", "SCT\r\nX-Injected: yes", ] { assert!( !is_safe_server_chan_send_key(unsafe_key), "unsafe key: {unsafe_key:?}" ); } } #[test] fn server_chan_desp_uses_template_when_provided() { let rendered = render_server_chan_desp(Some("**{title}**\n\n{body}\n\n--end--"), "告警", "原始正文") .expect("template should render"); assert_eq!(rendered, "**告警**\n\n原始正文\n\n--end--"); } #[test] fn server_chan_desp_falls_back_to_markdown_body_for_empty_template() { assert_eq!( render_server_chan_desp(None, "告警", "原始正文").expect("fallback should render"), "原始正文" ); assert_eq!( render_server_chan_desp(Some(" "), "告警", "原始正文") .expect("fallback should render"), "原始正文" ); } #[test] fn server_chan_desp_rejects_expansion_bombs_and_oversized_content() { let template = "{body}".repeat(super::MAX_SERVER_CHAN_TEMPLATE_BYTES / 6 + 1); assert!(render_server_chan_desp(Some(&template), "告警", "正文").is_err()); let body = "x".repeat(super::MAX_SERVER_CHAN_BODY_BYTES + 1); assert!(render_server_chan_desp(None, "告警", &body).is_err()); } }