diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 1ed6baf..df9249b 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2297,7 +2297,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | --- | ---- | ------- | | [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | | [`tls_domains`](#tls_domains) | `String[]` | `[]` | -| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"` | `"drop"` | +| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` | | [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` | | [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults | | [`mask`](#mask) | `bool` | `true` | @@ -2348,13 +2348,17 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche tls_domains = ["example.net", "example.org"] ``` ## unknown_sni_action - - **Constraints / validation**: `"drop"`, `"mask"` or `"accept"`. + - **Constraints / validation**: `"drop"`, `"mask"`, `"accept"` or `"reject_handshake"`. - **Description**: Action for TLS ClientHello with unknown / non-configured SNI. + - `drop` — close the connection without any response (silent FIN after `server_hello_delay` is applied). Timing-indistinguishable from the Success branch, but wire-quieter than what a real web server would do. + - `mask` — transparently proxy the connection to `mask_host:mask_port` (TLS fronting). The client receives a real ServerHello from the backend with its real certificate. Maximum camouflage, but opens an outbound connection for every misdirected request. + - `accept` — pretend the SNI is valid and continue on the auth path. Weakens active-probing resistance; only meaningful in narrow scenarios. + - `reject_handshake` — emit a fatal TLS `unrecognized_name` alert (RFC 6066, AlertDescription = 112) and close the connection. Identical on the wire to a modern nginx with `ssl_reject_handshake on;` on its default vhost: looks like an ordinary HTTPS server that simply does not host the requested name. Recommended when the goal is maximal parity with a stock web server rather than TLS fronting. `server_hello_delay` is intentionally **not** applied to this branch, so the alert is emitted "instantly" the way a reference nginx would. - **Example**: ```toml [censorship] - unknown_sni_action = "drop" + unknown_sni_action = "reject_handshake" ``` ## tls_fetch_scope - **Constraints / validation**: `String`. Value is trimmed during load; whitespace-only becomes empty. @@ -3110,5 +3114,3 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p username = "alice" password = "secret" ``` - - diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index d4b696a..df0819c 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -2224,7 +2224,7 @@ ``` ## relay_client_idle_soft_secs - **Ограничения / валидация**: Должно быть `> 0`; Должно быть меньше или равно `relay_client_idle_hard_secs`. - - **Описание**: Мягкий порог простоя (в секундах) для неактивности uplink клиента в промежуточном узле. При достижении этого порога сессия помечается как кандидат на простой и может быть удалена в зависимости от политики. + - **Описание**: Мягкий порог простоя (в секундах) для неактивности uplink клиента в промежуточном узле. При достижении этого порога сессия помечается как кандидат на простой и может быть удалена в зависимости от политики. - **Пример**: ```toml @@ -2303,7 +2303,7 @@ | --- | ---- | ------- | | [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | | [`tls_domains`](#tls_domains) | `String[]` | `[]` | -| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"` | `"drop"` | +| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"`, `"reject_handshake"` | `"drop"` | | [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` | | [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults | | [`mask`](#mask) | `bool` | `true` | @@ -2353,13 +2353,17 @@ tls_domains = ["example.net", "example.org"] ``` ## unknown_sni_action - - **Ограничения / валидация**: `"drop"`, `"mask"` или `"accept"`. + - **Ограничения / валидация**: `"drop"`, `"mask"`, `"accept"` или `"reject_handshake"`. - **Описание**: Действие для TLS ClientHello с неизвестным/ненастроенным SNI. + - `drop` — закрыть соединение без ответа (молчаливый FIN после применения `server_hello_delay`). Поведение, неотличимое по таймингу от Success-ветки, но более «тихое», чем у обычного веб-сервера. + - `mask` — прозрачно проксировать соединение на `mask_host:mask_port` (TLS-fronting). Клиент получает настоящий ServerHello от реального бэкенда с его сертификатом. Максимальный камуфляж, но порождает исходящее соединение на каждый чужой запрос. + - `accept` — притвориться, что SNI валиден, и продолжить auth-путь. Снижает защиту от активного пробинга; осмысленно только в узких сценариях. + - `reject_handshake` — отправить фатальный TLS-alert `unrecognized_name` (RFC 6066, AlertDescription = 112) и закрыть соединение. Поведение, идентичное современному nginx с `ssl_reject_handshake on;` на дефолтном vhost'е: на wire-уровне выглядит как обычный HTTPS-сервер, у которого просто нет такого домена. Рекомендуется, если цель — максимальная похожесть на стоковый веб-сервер, а не tls-fronting. `server_hello_delay` на эту ветку не применяется, чтобы alert улетал «мгновенно», как у эталонного nginx. - **Пример**: ```toml [censorship] - unknown_sni_action = "drop" + unknown_sni_action = "reject_handshake" ``` ## tls_fetch_scope - **Ограничения / валидация**: `String`. Значение обрезается во время загрузки; значение, состоящее только из пробелов, становится пустым. @@ -3117,5 +3121,3 @@ username = "alice" password = "secret" ``` - - diff --git a/docs/FAQ.en.md b/docs/FAQ.en.md index dec93f7..0e55c1f 100644 --- a/docs/FAQ.en.md +++ b/docs/FAQ.en.md @@ -210,6 +210,13 @@ If you need to allow connections with any domains (ignoring SNI mismatches), add unknown_sni_action = "mask" ``` +Alternatively, if you want telemt to behave like a vanilla nginx with `ssl_reject_handshake on;` on unknown SNI (emit a TLS `unrecognized_name` alert and close the connection), use: +```toml +[censorship] +unknown_sni_action = "reject_handshake" +``` +This does not recover stale clients, but it makes port 443 wire-indistinguishable from a stock web server that simply does not host the requested vhost. + ### How to view metrics 1. Open the configuration file: `nano /etc/telemt/telemt.toml`. diff --git a/docs/FAQ.ru.md b/docs/FAQ.ru.md index 73a2ba4..9b1ec52 100644 --- a/docs/FAQ.ru.md +++ b/docs/FAQ.ru.md @@ -227,6 +227,13 @@ curl -s http://127.0.0.1:9091/v1/users | jq unknown_sni_action = "mask" ``` +Альтернатива: если вы хотите, чтобы telemt на неизвестный SNI вёл себя как обычный nginx с `ssl_reject_handshake on;` (отдавал TLS-alert `unrecognized_name` и закрывал соединение), используйте: +```toml +[censorship] +unknown_sni_action = "reject_handshake" +``` +Это не пропускает старых клиентов, но делает поведение на 443-м порту неотличимым от стокового веб-сервера, у которого просто нет такого виртуального хоста. + ## Как посмотреть метрики 1. Откройте файл конфигурации: `nano /etc/telemt/telemt.toml`. diff --git a/src/config/load.rs b/src/config/load.rs index f80982f..55f38ca 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1977,6 +1977,22 @@ mod tests { cfg_accept.censorship.unknown_sni_action, UnknownSniAction::Accept ); + + let cfg_reject: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "reject_handshake" + "#, + ) + .unwrap(); + assert_eq!( + cfg_reject.censorship.unknown_sni_action, + UnknownSniAction::RejectHandshake + ); } #[test] diff --git a/src/config/types.rs b/src/config/types.rs index 2eede6d..9914b63 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1571,6 +1571,13 @@ pub enum UnknownSniAction { Drop, Mask, Accept, + /// Reject the TLS handshake by sending a fatal `unrecognized_name` alert + /// (RFC 6066, AlertDescription = 112) before closing the connection. + /// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost — + /// the wire response indistinguishable from a stock modern web server + /// that simply does not host the requested name. + #[serde(rename = "reject_handshake")] + RejectHandshake, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 904b8f9..a9ab0ff 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1132,9 +1132,20 @@ where "TLS handshake accepted by unknown SNI policy" ); } - action @ (UnknownSniAction::Drop | UnknownSniAction::Mask) => { + action @ (UnknownSniAction::Drop + | UnknownSniAction::Mask + | UnknownSniAction::RejectHandshake) => { auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; + // For Drop/Mask we apply the synthetic ServerHello delay so + // the fail-closed path is timing-indistinguishable from the + // success path. For RejectHandshake we deliberately skip the + // delay: a stock modern nginx with `ssl_reject_handshake on;` + // responds with the alert essentially immediately, so + // injecting 8-24ms here would itself become a distinguisher + // against the public baseline we are trying to blend into. + if !matches!(action, UnknownSniAction::RejectHandshake) { + maybe_apply_server_hello_delay(config).await; + } let log_now = Instant::now(); if should_emit_unknown_sni_warn_in(shared, log_now) { warn!( @@ -1153,8 +1164,33 @@ where "TLS handshake rejected by unknown SNI policy" ); } + if matches!(action, UnknownSniAction::RejectHandshake) { + // TLS alert record layer: + // 0x15 ContentType.alert + // 0x03 0x03 legacy_record_version = TLS 1.2 + // (matches what modern nginx emits in + // the first server -> client record, + // per RFC 8446 5.1 guidance) + // 0x00 0x02 length = 2 + // Alert payload: + // 0x02 AlertLevel.fatal + // 0x70 AlertDescription.unrecognized_name (112, RFC 6066) + const TLS_ALERT_UNRECOGNIZED_NAME: [u8; 7] = + [0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70]; + if let Err(e) = writer.write_all(&TLS_ALERT_UNRECOGNIZED_NAME).await { + debug!( + peer = %peer, + error = %e, + "Failed to write unrecognized_name TLS alert" + ); + } else { + let _ = writer.flush().await; + } + } return match action { - UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni), + UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => { + HandshakeResult::Error(ProxyError::UnknownTlsSni) + } UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, UnknownSniAction::Accept => unreachable!(), }; diff --git a/src/proxy/tests/handshake_security_tests.rs b/src/proxy/tests/handshake_security_tests.rs index 0f8fe03..df91cac 100644 --- a/src/proxy/tests/handshake_security_tests.rs +++ b/src/proxy/tests/handshake_security_tests.rs @@ -1007,6 +1007,55 @@ async fn tls_unknown_sni_mask_policy_falls_back_to_bad_client() { assert!(matches!(result, HandshakeResult::BadClient { .. })); } +#[tokio::test] +async fn tls_unknown_sni_reject_handshake_policy_emits_unrecognized_name_alert() { + use tokio::io::{AsyncReadExt, duplex}; + + let secret = [0x4Au8; 16]; + let mut config = test_config_with_secret_hex("4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a"); + config.censorship.unknown_sni_action = UnknownSniAction::RejectHandshake; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.192:44326".parse().unwrap(); + let handshake = + make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "unknown.example", &[b"h2"]); + + // Wire up a duplex so we can inspect what the server writes towards the + // client. We own the "peer side" half to read from it. + let (server_side, mut peer_side) = duplex(1024); + let (server_read, server_write) = tokio::io::split(server_side); + + let result = handle_tls_handshake( + &handshake, + server_read, + server_write, + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!( + result, + HandshakeResult::Error(ProxyError::UnknownTlsSni) + )); + + // Drain what the server wrote. We expect exactly one TLS alert record: + // 0x15 0x03 0x03 0x00 0x02 0x02 0x70 + // (ContentType.alert, TLS 1.2, length=2, fatal, unrecognized_name) + drop(result); // drops the server-side writer so peer_side sees EOF + let mut buf = Vec::new(); + peer_side.read_to_end(&mut buf).await.unwrap(); + assert_eq!( + buf, + [0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70], + "reject_handshake must emit a fatal unrecognized_name TLS alert" + ); +} + #[tokio::test] async fn tls_unknown_sni_accept_policy_continues_auth_path() { let secret = [0x4Bu8; 16];