mirror of
https://github.com/telemt/telemt.git
synced 2026-04-19 19:44:11 +03:00
Merge pull request #721 from lie-must-die/feat/unknown-sni-reject-handshake
Feat/unknown sni reject handshake
This commit is contained in:
@@ -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_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
|
||||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
|
| [`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_scope`](#tls_fetch_scope) | `String` | `""` |
|
||||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
||||||
| [`mask`](#mask) | `bool` | `true` |
|
| [`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"]
|
tls_domains = ["example.net", "example.org"]
|
||||||
```
|
```
|
||||||
## unknown_sni_action
|
## 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.
|
- **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**:
|
- **Example**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[censorship]
|
[censorship]
|
||||||
unknown_sni_action = "drop"
|
unknown_sni_action = "reject_handshake"
|
||||||
```
|
```
|
||||||
## tls_fetch_scope
|
## tls_fetch_scope
|
||||||
- **Constraints / validation**: `String`. Value is trimmed during load; whitespace-only becomes empty.
|
- **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"
|
username = "alice"
|
||||||
password = "secret"
|
password = "secret"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2224,7 +2224,7 @@
|
|||||||
```
|
```
|
||||||
## relay_client_idle_soft_secs
|
## relay_client_idle_soft_secs
|
||||||
- **Ограничения / валидация**: Должно быть `> 0`; Должно быть меньше или равно `relay_client_idle_hard_secs`.
|
- **Ограничения / валидация**: Должно быть `> 0`; Должно быть меньше или равно `relay_client_idle_hard_secs`.
|
||||||
- **Описание**: Мягкий порог простоя (в секундах) для неактивности uplink клиента в промежуточном узле. При достижении этого порога сессия помечается как кандидат на простой и может быть удалена в зависимости от политики.
|
- **Описание**: Мягкий порог простоя (в секундах) для неактивности uplink клиента в промежуточном узле. При достижении этого порога сессия помечается как кандидат на простой и может быть удалена в зависимости от политики.
|
||||||
- **Пример**:
|
- **Пример**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -2303,7 +2303,7 @@
|
|||||||
| --- | ---- | ------- |
|
| --- | ---- | ------- |
|
||||||
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
|
| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` |
|
||||||
| [`tls_domains`](#tls_domains) | `String[]` | `[]` |
|
| [`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_scope`](#tls_fetch_scope) | `String` | `""` |
|
||||||
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults |
|
||||||
| [`mask`](#mask) | `bool` | `true` |
|
| [`mask`](#mask) | `bool` | `true` |
|
||||||
@@ -2353,13 +2353,17 @@
|
|||||||
tls_domains = ["example.net", "example.org"]
|
tls_domains = ["example.net", "example.org"]
|
||||||
```
|
```
|
||||||
## unknown_sni_action
|
## unknown_sni_action
|
||||||
- **Ограничения / валидация**: `"drop"`, `"mask"` или `"accept"`.
|
- **Ограничения / валидация**: `"drop"`, `"mask"`, `"accept"` или `"reject_handshake"`.
|
||||||
- **Описание**: Действие для TLS ClientHello с неизвестным/ненастроенным SNI.
|
- **Описание**: Действие для 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
|
```toml
|
||||||
[censorship]
|
[censorship]
|
||||||
unknown_sni_action = "drop"
|
unknown_sni_action = "reject_handshake"
|
||||||
```
|
```
|
||||||
## tls_fetch_scope
|
## tls_fetch_scope
|
||||||
- **Ограничения / валидация**: `String`. Значение обрезается во время загрузки; значение, состоящее только из пробелов, становится пустым.
|
- **Ограничения / валидация**: `String`. Значение обрезается во время загрузки; значение, состоящее только из пробелов, становится пустым.
|
||||||
@@ -3117,5 +3121,3 @@
|
|||||||
username = "alice"
|
username = "alice"
|
||||||
password = "secret"
|
password = "secret"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ If you need to allow connections with any domains (ignoring SNI mismatches), add
|
|||||||
unknown_sni_action = "mask"
|
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
|
### How to view metrics
|
||||||
|
|
||||||
1. Open the configuration file: `nano /etc/telemt/telemt.toml`.
|
1. Open the configuration file: `nano /etc/telemt/telemt.toml`.
|
||||||
|
|||||||
@@ -227,6 +227,13 @@ curl -s http://127.0.0.1:9091/v1/users | jq
|
|||||||
unknown_sni_action = "mask"
|
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`.
|
1. Откройте файл конфигурации: `nano /etc/telemt/telemt.toml`.
|
||||||
|
|||||||
@@ -1977,6 +1977,22 @@ mod tests {
|
|||||||
cfg_accept.censorship.unknown_sni_action,
|
cfg_accept.censorship.unknown_sni_action,
|
||||||
UnknownSniAction::Accept
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1571,6 +1571,13 @@ pub enum UnknownSniAction {
|
|||||||
Drop,
|
Drop,
|
||||||
Mask,
|
Mask,
|
||||||
Accept,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1132,9 +1132,20 @@ where
|
|||||||
"TLS handshake accepted by unknown SNI policy"
|
"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());
|
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();
|
let log_now = Instant::now();
|
||||||
if should_emit_unknown_sni_warn_in(shared, log_now) {
|
if should_emit_unknown_sni_warn_in(shared, log_now) {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1153,8 +1164,33 @@ where
|
|||||||
"TLS handshake rejected by unknown SNI policy"
|
"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 {
|
return match action {
|
||||||
UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni),
|
UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => {
|
||||||
|
HandshakeResult::Error(ProxyError::UnknownTlsSni)
|
||||||
|
}
|
||||||
UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer },
|
UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer },
|
||||||
UnknownSniAction::Accept => unreachable!(),
|
UnknownSniAction::Accept => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1007,6 +1007,55 @@ async fn tls_unknown_sni_mask_policy_falls_back_to_bad_client() {
|
|||||||
assert!(matches!(result, HandshakeResult::BadClient { .. }));
|
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]
|
#[tokio::test]
|
||||||
async fn tls_unknown_sni_accept_policy_continues_auth_path() {
|
async fn tls_unknown_sni_accept_policy_continues_auth_path() {
|
||||||
let secret = [0x4Bu8; 16];
|
let secret = [0x4Bu8; 16];
|
||||||
|
|||||||
Reference in New Issue
Block a user