Compare commits

..

12 Commits

Author SHA1 Message Date
Alexey
c4b58ad374 Hardened TLS-F ServerHello selection
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 13:07:40 +03:00
Alexey
db7ff8737c Add dynamic SNI mask target mode
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-11 10:36:37 +03:00
Alexey
cd2bb9c8cd Alles muss man selber machen
Co-Authored-By: Mikhail I. Izmestev <355023+izmmisha@users.noreply.github.com>
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
Co-Authored-By: Dietmar Schreiber <376736+dginorg@users.noreply.github.com>
2026-06-11 10:13:17 +03:00
Alexey
8d3f8a8215 Merge pull request #828 from amirotin/feat/config-edit-api
Add config-edit HTTP API: PATCH/GET /v1/config
2026-06-10 10:30:52 +03:00
Mirotin Artem
ff7a12d5f8 fix(api): GET /v1/config returns only editable sections; tolerate commented TOML headers; doc fixes 2026-06-09 12:13:32 +03:00
Mirotin Artem
27ee634f4a docs(api): document PATCH/GET /v1/config 2026-06-09 12:03:35 +03:00
Mirotin Artem
d7e16f5b26 feat(api): config-edit endpoints PATCH/GET /v1/config 2026-06-09 12:03:28 +03:00
Mirotin Artem
e39aaeb5c5 feat(config): classify_config_changes (hot vs restart) via overlay_hot_fields 2026-06-09 12:03:10 +03:00
Mirotin Artem
1628a7d822 feat(api): generic config section writer + array-table bounds 2026-06-09 12:03:01 +03:00
Alexey
1096e38854 Docs for MSS Tuning
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:24:27 +03:00
Alexey
9bbdf796d8 Rustfmt 2026-06-06 12:17:19 +03:00
Alexey
27a5f5a4ec MSS Tuning with config
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-06 12:11:05 +03:00
18 changed files with 1592 additions and 93 deletions

3
Cargo.lock generated
View File

@@ -2790,7 +2790,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "telemt"
version = "3.4.14"
version = "3.4.15"
dependencies = [
"aes",
"anyhow",
@@ -2834,6 +2834,7 @@ dependencies = [
"socket2",
"static_assertions",
"subtle",
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",

View File

@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.4.14"
version = "3.4.15"
edition = "2024"
[features]
@@ -90,6 +90,7 @@ tokio-test = "0.4"
criterion = "0.8"
proptest = "1.4"
futures = "0.3"
tempfile = "3.27.0"
[[bench]]
name = "crypto_bench"

View File

@@ -106,6 +106,8 @@ Notes:
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
| `GET` | `/v1/config` | none | `200` | `ConfigData` |
| `PATCH` | `/v1/config` | sparse JSON object | `200` | `PatchConfigResponse` |
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
@@ -143,6 +145,8 @@ Notes:
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. |
| `PATCH /v1/config` | Applies a sparse patch to editable config sections; validates, writes, and reports restart impact. |
| `GET /v1/users` | Returns disk-first user views sorted by username. |
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
@@ -158,6 +162,8 @@ Notes:
| HTTP | `error.code` | Trigger |
| --- | --- | --- |
| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. |
| `400` | `access_not_editable` | `PATCH /v1/config` body contains an `access` key (managed via users API). |
| `400` | `section_not_editable` | `PATCH /v1/config` body contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
@@ -177,6 +183,7 @@ Notes:
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
| `DELETE /v1/config` (or any method not in `GET`, `PATCH`) | `405 method_not_allowed` with `Allow: GET, PATCH`. |
| `PUT /v1/users/{username}` | `405 method_not_allowed`. |
| `POST /v1/users/{username}` | `404 not_found`. |
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
@@ -245,6 +252,20 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
bob = ["198.51.100.42/32"]
```
### `PatchConfigRequest`
A sparse JSON object containing only the top-level config sections to modify. Each key must be one of the editable sections (`general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`). Tables within a section are deep-merged field-by-field into the existing config; arrays and scalar values replace the existing value wholesale. Untouched sections and file comments are preserved.
**Rejected keys:**
- `access``400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
- `server`, `network`, or any unknown top-level key → `400 section_not_editable`.
- An object with no editable keys → `400 bad_request` (empty patch).
Example — patch only the SNI domain:
```json
{"censorship": {"tls_domain": "front.example.com"}}
```
### `RotateSecretRequest`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
@@ -254,6 +275,31 @@ An empty request body is accepted and generates a new secret automatically.
## Response Data Contracts
### `ConfigData`
Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the editable TOML sections. The current revision is returned in the envelope `revision` field (same value as `config_hash` in `SystemInfoData`), **not** inside `data`.
| Field | Type | Description |
| --- | --- | --- |
| `general` | `object?` | `[general]` section, if present in config. |
| `timeouts` | `object?` | `[timeouts]` section, if present. |
| `censorship` | `object?` | `[censorship]` section, if present. |
| `upstreams` | `object?` | `[upstreams]` section, if present. |
| `show_link` | `object?` | `[show_link]` section, if present. |
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets), `server` (carries the API `auth_header` and per-node identity), and `network` (per-node addresses) are always excluded.
### `PatchConfigResponse`
Returned by `PATCH /v1/config` on success (`200`).
| Field | Type | Description |
| --- | --- | --- |
| `revision` | `string` | SHA-256 hex of the config file after the patch was written. |
| `restart_required` | `bool` | `true` when one or more changed fields require a process restart to take effect. Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher; restart-required fields (e.g. any `censorship.*`, `timeouts.*`, `upstreams`, or `general.modes` change) are written to disk but only take effect after the Telemt process is restarted. The caller is responsible for triggering a restart when this flag is `true`. |
| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). |
### `HealthData`
| Field | Type | Description |
| --- | --- | --- |
@@ -1279,10 +1325,101 @@ Link generation uses active config and enabled modes:
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
## Config Endpoints
### `GET /v1/config`
Returns the current editable config sections as TOML-shaped JSON, plus the current revision. The `access` section (users and secrets) is always stripped and never appears in the response.
**Auth:** requires `Authorization` header when `auth_header` is configured (same as all other endpoints).
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<sha256-hex>",
"censorship": {"tls_domain": "front.example.com"},
"general": {"log_level": "normal"}
}
```
Top-level sections absent from the config file are absent from the response. Only `GET` and `PATCH` are accepted; any other method returns `405 Method Not Allowed` with `Allow: GET, PATCH`.
---
### `PATCH /v1/config`
Applies a sparse patch to the editable config sections. The merged config is fully validated before writing; if validation fails the file is not modified.
**Auth:** requires `Authorization` header when `auth_header` is configured.
**Headers:**
| Header | Required | Description |
| --- | --- | --- |
| `Authorization` | when configured | Same token as all other endpoints. |
| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. |
| `If-Match: <revision>` | no | Optimistic concurrency. `<revision>` is the `revision` value from `GET /v1/config` or `config_hash` from `GET /v1/system/info`. If supplied and it does not match the current on-disk revision, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. |
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`.
**Rejected keys and their error codes:**
| Key | HTTP | `error.code` |
| --- | --- | --- |
| `access` | `400` | `access_not_editable` |
| `server`, `network`, or any unknown key | `400` | `section_not_editable` |
| Object with no editable key | `400` | `bad_request` |
**Merge semantics:** tables are deep-merged field-by-field; arrays and scalar values replace the existing value wholesale. File comments and untouched sections are preserved.
**Validation:** the merged config is deserialized into the full `ProxyConfig` type and validated before writing. Failures return `400` with a descriptive message; the file is not modified.
**Read-only mode:** returns `403 read_only` when the API runs with `read_only = true`.
**Success `200` response body** (`data` field of the standard envelope):
```json
{
"revision": "<new-sha256-hex>",
"restart_required": true,
"changed": ["censorship"]
}
```
- `revision` — SHA-256 hex of the config file after the write.
- `restart_required``true` when the change affects a field that Telemt cannot hot-reload (e.g. `censorship.*`, `timeouts.*`, `upstreams`, `general.modes`). Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher. Restart-required fields are written to disk but only take effect after the Telemt process is restarted; the caller is responsible for triggering the restart.
- `changed` — list of top-level section names that differed.
**Status codes:**
| HTTP | `error.code` | Condition |
| --- | --- | --- |
| `200` | — | Patch applied successfully. |
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
| `400` | `access_not_editable` | Patch contains an `access` key. |
| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. |
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
| `403` | `read_only` | API is in read-only mode. |
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
| `409` | `revision_conflict` | `If-Match` header supplied but does not match current revision. |
| `500` | `internal_error` | I/O or serialization failure. |
**curl example:**
```bash
# get current revision
curl -s -H "Authorization: <token>" http://127.0.0.1:<api>/v1/system/info | jq -r .config_hash
# patch the SNI domain with optimistic concurrency
curl -s -X PATCH -H "Authorization: <token>" -H "If-Match: <revision>" \
-H "Content-Type: application/json" \
-d '{"censorship":{"tls_domain":"front.example.com"}}' \
http://127.0.0.1:<api>/v1/config
```
## Mutation Semantics
| Endpoint | Notes |
| --- | --- |
| `PATCH /v1/config` | Deep-merges the patch into editable config sections (tables merged per-field; arrays/scalars replaced wholesale). Validates the merged result before writing. Writes only the touched sections via atomic `tmp + rename`. Returns the new revision and which sections changed. |
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |

View File

@@ -1805,6 +1805,7 @@ This document lists all configuration keys accepted by `config.toml`.
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -1887,6 +1888,16 @@ This document lists all configuration keys accepted by `config.toml`.
listen_unix_sock = "/run/telemt.sock"
listen_tcp = true
```
## client_mss
- **Constraints / validation**: `String`. Empty or omitted means do not change kernel MSS. Presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Custom decimal strings must be within `88..=4096`.
- **Description**: Client-facing TCP MSS applied to TCP listener sockets before `listen(2)`, so Linux can announce it in SYN/ACK. This affects only proxy client TCP listeners, not API, metrics, Unix sockets, Telegram upstreams, ME sockets, or mask backend connections. Changes require listener restart/rebind.
- **Performance note**: Low MSS increases packet count predictably. Approximate segment multiplier is `ceil(1460 / client_mss)`.
- **Example**:
```toml
[server]
client_mss = "tspu"
```
## proxy_protocol
- **Constraints / validation**: `bool`.
- **Description**: Enables HAProxy PROXY protocol parsing on incoming connections (PROXY v1/v2). When enabled, client source address is taken from the PROXY header.
@@ -2207,6 +2218,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2231,6 +2243,17 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
ip = "0.0.0.0"
port = 443
```
## client_mss (server.listeners)
- **Constraints / validation**: `String` (optional). Same values as `[server].client_mss`.
- **Description**: Per-listener MSS override. When omitted, inherits `[server].client_mss`; when set to an empty string, disables MSS shaping for this listener even if the global value is set. Changes require listener restart/rebind.
- **Example**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## announce
- **Constraints / validation**: `String` (optional). Must not be empty when set.
- **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`.

View File

@@ -1807,6 +1807,7 @@
| [`listen_unix_sock`](#listen_unix_sock) | `String` | — | `` |
| [`listen_unix_sock_perm`](#listen_unix_sock_perm) | `String` | — | `` |
| [`listen_tcp`](#listen_tcp) | `bool` | — (auto) | `` |
| [`client_mss`](#client_mss) | `String` | `""` | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | `false` | `` |
| [`proxy_protocol_header_timeout_ms`](#proxy_protocol_header_timeout_ms) | `u64` | `500` | `` |
| [`proxy_protocol_trusted_cidrs`](#proxy_protocol_trusted_cidrs) | `IpNetwork[]` | `[]` | `` |
@@ -1889,6 +1890,16 @@
listen_unix_sock = "/run/telemt.sock"
listen_tcp = true
```
## client_mss
- **Ограничения / валидация**: `String`. Пустое значение или отсутствие параметра означает, что Telemt не изменяет MSS, выбранный ядром. Поддерживаемые presets: `"extreme-low"` = `88`, `"tspu"` = `92`, `"2in8"` = `256`. Пользовательское десятичное значение должно быть строкой в диапазоне `88..=4096`.
- **Описание**: MSS для входящих TCP-соединений клиентов. Значение применяется к TCP listener-сокетам до `listen(2)`, чтобы Linux мог объявить его в SYN/ACK. Параметр влияет только на proxy client TCP listeners и не применяется к API, metrics, Unix sockets, Telegram upstreams, ME sockets или mask backend connections. Изменение требует restart/rebind listenerов.
- **Performance note**: Низкий MSS предсказуемо увеличивает количество TCP-сегментов. Приблизительный multiplier: `ceil(1460 / client_mss)`.
- **Пример**:
```toml
[server]
client_mss = "tspu"
```
## proxy_protocol
- **Ограничения / валидация**: `bool`.
- **Описание**: Включает поддержку разбора PROXY protocol от HAProxy (v1/v2) на входящих соединениях. При включении исходный IP клиента берётся из PROXY-заголовка.
@@ -2213,6 +2224,7 @@
| --- | ---- | ------- | ---------- |
| [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2237,6 +2249,17 @@
ip = "0.0.0.0"
port = 443
```
## client_mss (server.listeners)
- **Ограничения / валидация**: `String` (необязательный параметр). Допустимые значения совпадают с `[server].client_mss`.
- **Описание**: Per-listener override для MSS. Если параметр не задан, listener наследует `[server].client_mss`; если задана пустая строка, MSS shaping отключается только для этого listenerа, даже когда глобальный параметр задан. Изменение требует restart/rebind listenerа.
- **Пример**:
```toml
[[server.listeners]]
ip = "0.0.0.0"
port = 443
client_mss = "256"
```
## announce
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.
- **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listenerа. Имеет приоритет над `announce_ip`.

334
src/api/config_edit.rs Normal file
View File

@@ -0,0 +1,334 @@
//! Config-editing API: read managed sections and apply sparse field patches.
//! `access.*` is intentionally not editable here (owned by the users API).
use serde_json::Value as Json;
use toml::Value as Toml;
use super::ApiShared;
use super::config_store::{
EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk,
};
use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
pub(super) struct PatchConfigResponse {
pub revision: String,
pub restart_required: bool,
pub changed: Vec<String>,
}
/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config
/// mutations behind `mutation_lock`, then records a runtime event. The route
/// handler calls this; the core logic stays decoupled for unit tests.
pub(super) async fn patch_config(
patch_json: Json,
expected_revision: Option<String>,
shared: &ApiShared,
) -> Result<PatchConfigResponse, ApiFailure> {
let _guard = shared.mutation_lock.lock().await;
let resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
drop(_guard);
shared
.runtime_events
.record("api.config.patch.ok", format!("changed={:?}", resp.changed));
Ok(resp)
}
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
/// against a temp file. The route handler holds `mutation_lock` while calling this.
pub(super) async fn apply_patch_to_path(
config_path: &Path,
patch_json: &Json,
expected_revision: Option<String>,
) -> Result<PatchConfigResponse, ApiFailure> {
// 1. optimistic concurrency
let current = current_revision(config_path).await?;
if expected_revision.is_some_and(|expected| expected != current) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
"revision_conflict",
"Config revision mismatch",
));
}
// 2. convert + reject access / unknown sections
let patch_toml = json_to_toml(patch_json)
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
let patch_table = patch_toml
.as_table()
.ok_or_else(|| ApiFailure::bad_request("patch must be a JSON object"))?;
if patch_table.contains_key("access") {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"access_not_editable",
"access.* is managed via the users API, not editable here",
));
}
for key in patch_table.keys() {
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
return Err(ApiFailure::new(
hyper::StatusCode::BAD_REQUEST,
"section_not_editable",
format!("section not editable: {}", key),
));
}
}
let touched: Vec<&str> = patch_table
.keys()
.map(|k| k.as_str())
.filter(|k| EDITABLE_SECTIONS.contains(k))
.collect();
if touched.is_empty() {
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
}
// 3. Parse old + merged from the SAME deserialize path so the classifier
// sees only the delta this patch introduces. `ProxyConfig::load` applies
// include-expansion / legacy-compat / normalization that a bare
// `try_into` does not; mixing the two paths would make unrelated fields
// compare unequal and spuriously force `restart_required`.
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let original_toml: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let old_cfg: ProxyConfig = original_toml
.clone()
.try_into()
.map_err(|e| ApiFailure::internal(format!("config does not deserialize: {}", e)))?;
let mut merged = original_toml;
deep_merge(&mut merged, &patch_toml);
let new_cfg: ProxyConfig = merged
.clone()
.try_into()
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
new_cfg
.validate()
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
// 4. classify changes (Telemt's own hot/restart rule)
let class = classify_config_changes(&old_cfg, &new_cfg);
// 5. write only the touched top-level sections
let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?;
Ok(PatchConfigResponse {
revision,
restart_required: class.restart_required,
changed: class.changed,
})
}
/// Return only the editable config sections + current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let original = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
let parsed: Toml = toml::from_str(&original)
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
let parsed_table = parsed
.as_table()
.cloned()
.unwrap_or_else(toml::value::Table::new);
// Whitelist: return ONLY the editable sections. A blacklist (just removing
// `access`) would leak `server` (carries the API `auth_header` + per-node
// identity) and `network` (per-node addresses). Mirror the PATCH contract.
let mut table = toml::value::Table::new();
for section in EDITABLE_SECTIONS {
if let Some(value) = parsed_table.get(*section) {
table.insert((*section).to_string(), value.clone());
}
}
let revision = compute_revision(&original);
Ok((Toml::Table(table), revision))
}
/// Convert a serde_json value to a toml value. `null` is dropped from objects
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
/// otherwise floats.
fn json_to_toml(j: &Json) -> Result<Toml, String> {
Ok(match j {
Json::Null => return Err("null is not representable in TOML".into()),
Json::Bool(b) => Toml::Boolean(*b),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
Toml::Integer(i)
} else if let Some(f) = n.as_f64() {
Toml::Float(f)
} else {
return Err(format!("unrepresentable number: {}", n));
}
}
Json::String(s) => Toml::String(s.clone()),
Json::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(json_to_toml(item)?);
}
Toml::Array(out)
}
Json::Object(map) => {
let mut table = toml::value::Table::new();
for (k, v) in map {
if v.is_null() {
continue; // skip nulls instead of erroring at object level
}
table.insert(k.clone(), json_to_toml(v)?);
}
Toml::Table(table)
}
})
}
/// Recursively overlay `patch` onto `base`. Tables merge key-by-key; every
/// other value type (scalars, arrays) replaces wholesale.
fn deep_merge(base: &mut Toml, patch: &Toml) {
match (base, patch) {
(Toml::Table(b), Toml::Table(p)) => {
for (k, pv) in p {
match b.get_mut(k) {
Some(bv) => deep_merge(bv, pv),
None => {
b.insert(k.clone(), pv.clone());
}
}
}
}
(b, p) => *b = p.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_object_converts_to_toml_table() {
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
let t = json_to_toml(&j).expect("convertible");
let table = t.as_table().unwrap();
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
assert_eq!(table["default_dc"].as_integer(), Some(2));
}
#[test]
fn deep_merge_overlays_tables_and_replaces_scalars() {
let mut base: Toml =
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
deep_merge(&mut base, &patch);
let cens = base["censorship"].as_table().unwrap();
assert_eq!(cens["tls_domain"].as_str(), Some("new")); // overlaid
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100)); // preserved
}
use std::path::PathBuf;
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, body).unwrap();
(path, dir)
}
#[tokio::test]
async fn patch_rejects_access_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "access_not_editable");
}
#[tokio::test]
async fn patch_revision_conflict() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
.await
.unwrap_err();
assert_eq!(err.code, "revision_conflict");
}
#[tokio::test]
async fn patch_sni_reports_restart_required() {
let (path, _d) =
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "censorship"));
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"b.com\""));
assert_eq!(
resp.revision,
crate::api::config_store::compute_revision(&written)
);
}
#[tokio::test]
async fn read_managed_config_strips_access() {
let (path, _d) = temp_config(
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"deadbeef\"\n",
);
let (value, revision) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("access")); // secrets never leave the box here
assert_eq!(revision, current_revision(&path).await.unwrap());
}
#[tokio::test]
async fn read_managed_config_returns_only_editable_sections() {
// server carries the API auth_header + per-node identity; network carries
// per-node addresses. Neither must be exposed by GET /v1/config.
let (path, _d) = temp_config(concat!(
"[censorship]\ntls_domain = \"a\"\n",
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
"[network]\nipv4 = \"1.2.3.4\"\n",
"[access.users]\nbob = \"deadbeef\"\n",
));
let (value, _rev) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("censorship"));
assert!(!table.contains_key("server")); // no API auth_header / identity leak
assert!(!table.contains_key("network")); // no per-node identity leak
assert!(!table.contains_key("access")); // no users/secrets
}
#[tokio::test]
async fn patch_rejects_server_section() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"server": {"port": 1}});
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(err.code, "section_not_editable");
}
#[tokio::test]
async fn patch_empty_is_rejected() {
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({});
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
}
#[tokio::test]
async fn patch_log_level_is_hot() {
// general.log_level is hot-reloadable -> a patch changing only it must
// report restart_required = false (exercises the full apply path, not
// just the classifier). Default LogLevel is Normal; patch to "debug".
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
assert!(!resp.restart_required);
assert!(resp.changed.iter().any(|c| c == "general"));
}
}

View File

@@ -97,6 +97,81 @@ pub(super) async fn save_config_to_disk(
Ok(compute_revision(&serialized))
}
/// Top-level config tables that may be edited via the config API.
///
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node
/// identity invariant at the Telemt layer too):
///
/// - `access` : owned by the users API.
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
///
/// A future field-level allowlist can re-admit specific safe fields
/// (e.g. `network.dns_overrides`) without opening the whole section.
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
"general",
"timeouts",
"censorship",
"upstreams",
"show_link",
"dc_overrides",
];
/// Re-render the given top-level tables from `cfg` and upsert each into the
/// on-disk file, preserving every untouched section (and its comments).
pub(super) async fn save_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
sections: &[&str],
) -> Result<String, ApiFailure> {
let mut content = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
for section in sections {
let rendered = render_top_level_section(cfg, section)?;
content = upsert_toml_table(&content, section, &rendered);
}
write_atomic(config_path.to_path_buf(), content.clone()).await?;
Ok(compute_revision(&content))
}
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
/// output matches the canonical format Telemt parses.
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
let value = toml::Value::try_from(cfg)
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
let table = value
.get(section)
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
if let toml::Value::Array(items) = table {
let mut out = String::new();
for item in items {
out.push_str(&format!("[[{}]]\n", section));
out.push_str(&toml::to_string(item).map_err(|e| {
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
})?);
if !out.ends_with('\n') {
out.push('\n');
}
}
return Ok(out);
}
let body = toml::to_string(table)
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
let mut out = format!("[{}]\n", section);
out.push_str(&body);
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
pub(super) async fn save_access_sections_to_disk(
config_path: &Path,
cfg: &ProxyConfig,
@@ -273,17 +348,22 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
}
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
let target = format!("[{}]", table_name);
let single = format!("[{}]", table_name);
let array = format!("[[{}]]", table_name);
let mut offset = 0usize;
let mut start = None;
for line in source.split_inclusive('\n') {
let trimmed = line.trim();
// Drop any inline comment so a hand-edited header like
// `[censorship] # note` still matches. Section names never contain `#`.
let header = line.trim().split('#').next().unwrap_or("").trim();
if let Some(start_offset) = start {
if trimmed.starts_with('[') {
let is_same_array = header == array;
let is_new_header = header.starts_with('[');
if is_new_header && !is_same_array {
return Some((start_offset, offset));
}
} else if trimmed == target {
} else if header == single || header == array {
start = Some(offset);
}
offset = offset.saturating_add(line.len());
@@ -336,6 +416,57 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
mod tests {
use super::*;
#[tokio::test]
async fn save_sections_preserves_other_tables_and_comments() {
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(
&path,
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
)
.unwrap();
let mut cfg = ProxyConfig::default();
cfg.censorship.tls_domain = "new.example".to_string();
cfg.server.port = 443;
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
.await
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("tls_domain = \"new.example\""));
assert!(written.contains("# top comment")); // untouched comment kept
assert!(written.contains("[server]\nport = 443")); // untouched table kept
assert_eq!(rev, compute_revision(&written));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn find_bounds_matches_array_of_tables() {
let src =
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
let bounds = find_toml_table_bounds(src, "upstreams");
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[[upstreams]]"));
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
}
#[test]
fn find_bounds_matches_header_with_inline_comment() {
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
let bounds = find_toml_table_bounds(src, "censorship");
assert!(bounds.is_some(), "commented header must still match");
let (start, end) = bounds.unwrap();
let slice = &src[start..end];
assert!(slice.starts_with("[censorship] # notes"));
assert!(slice.contains("tls_domain"));
assert!(!slice.contains("[server]")); // terminates at the next header
}
#[test]
fn render_user_rate_limits_section() {
let mut cfg = ProxyConfig::default();

View File

@@ -28,6 +28,7 @@ use crate::stats::Stats;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
mod config_edit;
mod config_store;
mod events;
mod http_utils;
@@ -84,6 +85,7 @@ const ALLOW_GET: &str = "GET";
const ALLOW_POST: &str = "POST";
const ALLOW_GET_POST: &str = "GET, POST";
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
const ALLOW_GET_PATCH: &str = "GET, PATCH";
pub(super) struct ApiRuntimeState {
pub(super) process_started_at_epoch_secs: u64,
@@ -174,6 +176,7 @@ fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
| "/v1/stats/users/quota"
| "/v1/stats/users" => Some(ALLOW_GET),
"/v1/users" => Some(ALLOW_GET_POST),
"/v1/config" => Some(ALLOW_GET_PATCH),
_ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST),
_ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST),
@@ -643,6 +646,37 @@ async fn handle(
};
Ok(success_response(status, data, revision))
}
("GET", "/v1/config") => {
let (value, revision) =
config_edit::read_managed_config(&shared.config_path).await?;
Ok(success_response(StatusCode::OK, value, revision))
}
("PATCH", "/v1/config") => {
if api_cfg.read_only {
return Ok(error_response(
request_id,
ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
),
));
}
let expected_revision = parse_if_match(req.headers());
let body = read_json::<serde_json::Value>(req.into_body(), body_limit).await?;
match config_edit::patch_config(body, expected_revision, &shared).await {
Ok(resp) => {
let revision = resp.revision.clone();
Ok(success_response(StatusCode::OK, resp, revision))
}
Err(error) => {
shared
.runtime_events
.record("api.config.patch.failed", error.code);
Err(error)
}
}
}
_ => {
if method == Method::POST
&& let Some(base_user) = normalized_path

View File

@@ -312,6 +312,7 @@ fn listeners_equal(
lhs.iter().zip(rhs.iter()).all(|(a, b)| {
a.ip == b.ip
&& a.port == b.port
&& a.client_mss == b.client_mss
&& a.announce == b.announce
&& a.announce_ip == b.announce_ip
&& a.proxy_protocol == b.proxy_protocol
@@ -608,6 +609,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4
|| old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6
|| old.server.listen_tcp != new.server.listen_tcp
|| old.server.client_mss != new.server.client_mss
|| old.server.listen_unix_sock != new.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
{
@@ -618,6 +620,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.censorship.tls_domains != new.censorship.tls_domains
|| old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope
|| old.censorship.mask != new.censorship.mask
|| old.censorship.mask_dynamic != new.censorship.mask_dynamic
|| old.censorship.mask_host != new.censorship.mask_host
|| old.censorship.mask_port != new.censorship.mask_port
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask
@@ -1487,6 +1490,48 @@ pub fn spawn_config_watcher(
(config_rx, log_rx)
}
// ── Change classification ─────────────────────────────────────────────────────
/// Which top-level config sections changed and whether any require a restart.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct ChangeClassification {
pub changed: Vec<String>,
pub restart_required: bool,
}
/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and
/// see if anything non-hot remains different. This guarantees `restart_required`
/// matches actual runtime behavior and never drifts as new fields are added.
pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification {
let applied = overlay_hot_fields(old, new);
let restart_required = !config_equal(&applied, new);
ChangeClassification {
changed: changed_sections(old, new),
restart_required,
}
}
/// Top-level config sections whose canonical serialized form differs between
/// old and new. Uses the same serialize+canonicalize path as `config_equal`.
fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null);
let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null);
canonicalize_json(&mut lhs);
canonicalize_json(&mut rhs);
let mut out = Vec::new();
if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) {
let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect();
keys.extend(ro.keys());
for key in keys {
if lo.get(key) != ro.get(key) {
out.push(key.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1659,6 +1704,41 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn classify_sni_change_requires_restart() {
// censorship.* is not in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.censorship.tls_domain = "front.example".to_string();
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
assert!(class.changed.iter().any(|c| c == "censorship"));
}
#[test]
fn classify_dns_overrides_change_is_hot() {
// network.dns_overrides IS in overlay_hot_fields -> no restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.network.dns_overrides.push("1.1.1.1".to_string());
let class = classify_config_changes(&old, &new);
assert!(!class.restart_required);
assert!(class.changed.iter().any(|c| c == "network"));
}
#[test]
fn classify_timeouts_change_requires_restart() {
// timeouts.* is NOT in overlay_hot_fields -> restart.
let old = ProxyConfig::default();
let mut new = ProxyConfig::default();
new.timeouts.client_handshake = old.timeouts.client_handshake + 1;
let class = classify_config_changes(&old, &new);
assert!(class.restart_required);
}
#[test]
fn reload_recovers_after_parse_error_on_next_attempt() {
let initial_tag = "cccccccccccccccccccccccccccccccc";

View File

@@ -299,6 +299,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[
"listen_unix_sock",
"listen_unix_sock_perm",
"listen_tcp",
"client_mss",
"proxy_protocol",
"proxy_protocol_header_timeout_ms",
"proxy_protocol_trusted_cidrs",
@@ -344,6 +345,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip",
"port",
"client_mss",
"announce",
"announce_ip",
"proxy_protocol",
@@ -370,6 +372,7 @@ const CENSORSHIP_CONFIG_KEYS: &[&str] = &[
"tls_fetch_scope",
"tls_fetch",
"mask",
"mask_dynamic",
"mask_host",
"mask_port",
"exclusive_mask",
@@ -1933,6 +1936,20 @@ impl ProxyConfig {
));
}
config
.server
.client_mss_value()
.map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?;
for (idx, listener) in config.server.listeners.iter().enumerate() {
if listener.client_mss.is_some() {
listener
.effective_client_mss(&config.server)
.map_err(|error| {
ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}"))
})?;
}
}
if config.server.accept_permit_timeout_ms > 60_000 {
return Err(ProxyError::Config(
"server.accept_permit_timeout_ms must be within [0, 60000]".to_string(),
@@ -2031,11 +2048,6 @@ impl ProxyConfig {
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
}
// Default mask_host to tls_domain if not set and no unix socket configured.
if config.censorship.mask_host.is_none() && config.censorship.mask_unix_sock.is_none() {
config.censorship.mask_host = Some(config.censorship.tls_domain.clone());
}
for (domain, target) in &config.censorship.exclusive_mask {
if !is_valid_tls_domain_name(domain) {
return Err(ProxyError::Config(format!(
@@ -2173,6 +2185,7 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig {
ip: ipv4,
port: Some(config.server.port),
client_mss: None,
announce: None,
announce_ip: None,
proxy_protocol: None,
@@ -2185,6 +2198,7 @@ impl ProxyConfig {
config.server.listeners.push(ListenerConfig {
ip: ipv6,
port: Some(config.server.port),
client_mss: None,
announce: None,
announce_ip: None,
proxy_protocol: None,
@@ -2460,6 +2474,7 @@ mod tests {
assert_eq!(cfg.general.update_every, default_update_every());
assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4());
assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt());
assert_eq!(cfg.server.client_mss_value(), Ok(None));
assert_eq!(
cfg.server.proxy_protocol_trusted_cidrs,
default_proxy_protocol_trusted_cidrs()
@@ -3787,6 +3802,153 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_presets_and_listener_override_are_resolved() {
let toml = r#"
[server]
client_mss = "tspu"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
[[server.listeners]]
ip = "127.0.0.2"
port = 1444
client_mss = "2in8"
[[server.listeners]]
ip = "127.0.0.3"
port = 1445
client_mss = ""
[[server.listeners]]
ip = "127.0.0.4"
port = 1446
client_mss = "extreme-low"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
assert_eq!(
cfg.server.listeners[0].effective_client_mss(&cfg.server),
Ok(Some(92))
);
assert_eq!(
cfg.server.listeners[1].effective_client_mss(&cfg.server),
Ok(Some(256))
);
assert_eq!(
cfg.server.listeners[2].effective_client_mss(&cfg.server),
Ok(None)
);
assert_eq!(
cfg.server.listeners[3].effective_client_mss(&cfg.server),
Ok(Some(88))
);
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_custom_value_is_accepted() {
let toml = r#"
[server]
client_mss = "4096"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_custom_valid_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_out_of_range_is_rejected() {
for value in ["87", "4097"] {
let toml = format!(
r#"
[server]
client_mss = "{value}"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#
);
let dir = std::env::temp_dir();
let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml"));
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.client_mss custom value must be within [88, 4096]"));
let _ = std::fs::remove_file(path);
}
}
#[test]
fn client_mss_unquoted_number_is_rejected() {
let toml = r#"
[server]
client_mss = 256
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_unquoted_number_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("client_mss"));
let _ = std::fs::remove_file(path);
}
#[test]
fn listener_client_mss_invalid_preset_is_rejected() {
let toml = r#"
[[server.listeners]]
ip = "127.0.0.1"
port = 1443
client_mss = "tiny"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_listener_client_mss_invalid_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("server.listeners[0].client_mss"));
assert!(err.contains("must be \"\", extreme-low, tspu, 2in8"));
let _ = std::fs::remove_file(path);
}
#[test]
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
let toml = r#"

View File

@@ -1451,6 +1451,11 @@ pub struct ServerConfig {
#[serde(default)]
pub listen_tcp: Option<bool>,
/// Client-facing TCP MSS preset or custom value for all TCP listeners.
/// Empty string or omitted value keeps the kernel default.
#[serde(default)]
pub client_mss: Option<String>,
/// Accept HAProxy PROXY protocol headers on incoming connections.
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
#[serde(default)]
@@ -1517,6 +1522,7 @@ impl Default for ServerConfig {
listen_unix_sock: None,
listen_unix_sock_perm: None,
listen_tcp: None,
client_mss: None,
proxy_protocol: false,
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
@@ -1720,6 +1726,10 @@ pub struct AntiCensorshipConfig {
#[serde(default = "default_true")]
pub mask: bool,
/// Use the ClientHello SNI as the mask TCP target for configured TLS domains.
#[serde(default = "default_true")]
pub mask_dynamic: bool,
#[serde(default)]
pub mask_host: Option<String>,
@@ -1855,6 +1865,7 @@ impl Default for AntiCensorshipConfig {
tls_fetch_scope: default_tls_fetch_scope(),
tls_fetch: TlsFetchConfig::default(),
mask: default_true(),
mask_dynamic: default_true(),
mask_host: None,
mask_port: default_mask_port(),
exclusive_mask: HashMap::new(),
@@ -2087,6 +2098,10 @@ pub struct ListenerConfig {
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)]
pub port: Option<u16>,
/// Per-listener client-facing TCP MSS preset or custom value.
/// Empty string disables MSS shaping for this listener.
#[serde(default)]
pub client_mss: Option<String>,
/// IP address or hostname to announce in proxy links.
/// Takes precedence over `announce_ip` if both are set.
#[serde(default)]
@@ -2104,6 +2119,64 @@ pub struct ListenerConfig {
pub reuse_allow: bool,
}
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
pub const CLIENT_MSS_EXTREME_LOW: u16 = 88;
/// Client-facing TCP MSS preset matching TSPU-oriented deployments.
pub const CLIENT_MSS_TSPU: u16 = 92;
/// Client-facing TCP MSS preset for 2-in-8 segment shaping.
pub const CLIENT_MSS_2IN8: u16 = 256;
/// Minimum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW;
/// Maximum accepted custom client-facing TCP MSS value.
pub const CLIENT_MSS_MAX: u16 = 4096;
impl ServerConfig {
/// Resolves the global client-facing TCP MSS setting.
pub fn client_mss_value(&self) -> std::result::Result<Option<u16>, String> {
parse_client_mss(self.client_mss.as_deref())
}
}
impl ListenerConfig {
/// Resolves the listener MSS override, falling back to the global server value.
pub fn effective_client_mss(
&self,
server: &ServerConfig,
) -> std::result::Result<Option<u16>, String> {
match self.client_mss.as_deref() {
Some(value) => parse_client_mss(Some(value)),
None => server.client_mss_value(),
}
}
}
fn parse_client_mss(raw: Option<&str>) -> std::result::Result<Option<u16>, String> {
let Some(raw) = raw else {
return Ok(None);
};
let value = raw.trim();
if value.is_empty() {
return Ok(None);
}
match value.to_ascii_lowercase().as_str() {
"extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)),
"tspu" => return Ok(Some(CLIENT_MSS_TSPU)),
"2in8" => return Ok(Some(CLIENT_MSS_2IN8)),
_ => {}
}
let parsed = value
.parse::<u16>()
.map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?;
if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) {
return Err(format!(
"custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]"
));
}
Ok(Some(parsed))
}
// ============= ShowLink =============
/// Controls which users' proxy links are displayed at startup.

View File

@@ -47,6 +47,10 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
.unwrap_or(config.server.port)
}
fn mss_segment_multiplier(client_mss: u16) -> u16 {
1460u16.div_ceil(client_mss)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn bind_listeners(
config: &Arc<ProxyConfig>,
@@ -90,10 +94,22 @@ pub(crate) async fn bind_listeners(
warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]");
continue;
}
let client_mss = match listener_conf.effective_client_mss(&config.server) {
Ok(value) => value,
Err(error) => {
warn!(
%addr,
error = %error,
"Invalid listener client MSS after config validation; using kernel default"
);
None
}
};
let options = ListenOptions {
reuse_port: listener_conf.reuse_allow,
ipv6_only: listener_conf.ip.is_ipv6(),
backlog: config.server.listen_backlog,
client_mss,
..Default::default()
};
@@ -101,6 +117,14 @@ pub(crate) async fn bind_listeners(
Ok(socket) => {
let listener = TcpListener::from_std(socket.into())?;
info!("Listening on {}", addr);
if let Some(client_mss) = client_mss {
info!(
%addr,
client_mss,
segment_multiplier = mss_segment_multiplier(client_mss),
"Client-facing TCP MSS configured"
);
}
let listener_proxy_protocol = listener_conf
.proxy_protocol
.unwrap_or(config.server.proxy_protocol);

View File

@@ -1239,6 +1239,18 @@ fn test_gen_fake_x25519_key() {
assert_ne!(key1, key2);
}
#[test]
fn test_gen_fake_x25519mlkem768_server_key_share_shape() {
let rng = crate::crypto::SecureRandom::new();
let key_share = gen_fake_x25519mlkem768_server_key_share(&rng);
assert_eq!(key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN);
assert!(
key_share.iter().any(|byte| *byte != 0),
"hybrid ServerHello key_share must not collapse to all-zero bytes"
);
}
#[test]
fn test_fake_x25519_key_is_nonzero_and_varies() {
let rng = crate::crypto::SecureRandom::new();
@@ -1325,6 +1337,65 @@ fn server_hello_extension_types(record: &[u8]) -> Vec<u16> {
out
}
fn server_hello_key_share(record: &[u8]) -> Option<(u16, usize)> {
if record.len() < 9 || record[0] != TLS_RECORD_HANDSHAKE || record[5] != 0x02 {
return None;
}
let record_len = u16::from_be_bytes([record[3], record[4]]) as usize;
if record.len() < 5 + record_len {
return None;
}
let hs_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize;
let hs_start = 5;
let hs_end = hs_start + 4 + hs_len;
if hs_end > record.len() {
return None;
}
let mut pos = hs_start + 4 + 2 + 32;
if pos >= hs_end {
return None;
}
let sid_len = record[pos] as usize;
pos += 1 + sid_len;
if pos + 2 + 1 + 2 > hs_end {
return None;
}
pos += 2 + 1;
let ext_len = u16::from_be_bytes([record[pos], record[pos + 1]]) as usize;
pos += 2;
let ext_end = pos + ext_len;
if ext_end > hs_end {
return None;
}
while pos + 4 <= ext_end {
let etype = u16::from_be_bytes([record[pos], record[pos + 1]]);
let elen = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
pos += 4;
if pos + elen > ext_end {
return None;
}
if etype == extension_type::KEY_SHARE {
if elen < 4 {
return None;
}
let group = u16::from_be_bytes([record[pos], record[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize;
if 4 + key_exchange_len != elen {
return None;
}
return Some((group, key_exchange_len));
}
pos += elen;
}
None
}
#[test]
fn build_server_hello_never_places_alpn_in_server_hello_extensions() {
let secret = b"alpn_sh_forbidden";
@@ -1395,14 +1466,21 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
!exts.contains(&0x0010),
"ALPN extension must not appear in emulated ServerHello"
);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
fn test_tls_extension_builder() {
let key = [0x42u8; 32];
let key = vec![0x42u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let mut builder = TlsExtensionBuilder::new();
builder.add_key_share(&key);
builder.add_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key);
builder.add_supported_versions(0x0304);
let result = builder.build();
@@ -1415,10 +1493,10 @@ fn test_tls_extension_builder() {
#[test]
fn test_server_hello_builder() {
let session_id = vec![0x01, 0x02, 0x03, 0x04];
let key = [0x55u8; 32];
let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id.clone())
.with_x25519_key(&key)
.with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version();
let record = builder.build_record();
@@ -1452,6 +1530,41 @@ fn test_build_server_hello_structure() {
let app_start = ccs_start + ccs_len;
assert!(response.len() > app_start + 5);
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() {
let secret = b"test secret";
let client_digest = [0x42u8; 32];
let session_id = vec![0xAA; 32];
let rng = crate::crypto::SecureRandom::new();
let response = build_server_hello_with_cipher(
secret,
&client_digest,
&session_id,
2048,
&rng,
[0x13, 0x01],
None,
0,
);
assert_eq!(
server_hello_key_share(&response),
Some((
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_SERVER_KEY_SHARE_LEN
))
);
}
#[test]
@@ -1474,10 +1587,10 @@ fn test_build_server_hello_digest() {
#[test]
fn test_server_hello_extensions_length() {
let session_id = vec![0x01; 32];
let key = [0x55u8; 32];
let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
let builder = ServerHelloBuilder::new(session_id)
.with_x25519_key(&key)
.with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key)
.with_tls13_version();
let record = builder.build_record();
@@ -1513,6 +1626,23 @@ fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x01]], exts, host)
}
fn client_key_share_extension(entries: &[(u16, usize)]) -> Vec<u8> {
let mut shares = Vec::new();
for (group, key_exchange_len) in entries {
assert!(*key_exchange_len <= u16::MAX as usize);
shares.extend_from_slice(&group.to_be_bytes());
shares.extend_from_slice(&(*key_exchange_len as u16).to_be_bytes());
let start = shares.len();
shares.resize(start + *key_exchange_len, 0x42);
}
assert!(shares.len() <= u16::MAX as usize);
let mut extension = Vec::new();
extension.extend_from_slice(&(shares.len() as u16).to_be_bytes());
extension.extend_from_slice(&shares);
extension
}
fn build_client_hello_with_ciphers_and_exts(
cipher_suites: &[[u8; 2]],
exts: Vec<(u16, Vec<u8>)>,
@@ -1674,7 +1804,7 @@ fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() {
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x03]),
[0x13, 0x03]
Some([0x13, 0x03])
);
}
@@ -1687,7 +1817,16 @@ fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() {
);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]),
[0x13, 0x03]
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_rejects_without_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0xc0, 0x2f]], Vec::new(), "example.com");
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
None
);
}
@@ -1696,21 +1835,73 @@ fn select_server_hello_cipher_suite_falls_back_to_offered_tls13_suite() {
let ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
[0x13, 0x03]
Some([0x13, 0x03])
);
}
#[test]
fn select_server_hello_cipher_suite_keeps_preferred_for_malformed_clienthello() {
fn select_server_hello_cipher_suite_rejects_malformed_clienthello() {
let mut ch =
build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com");
ch.truncate(12);
assert_eq!(
select_server_hello_cipher_suite(&ch, [0x13, 0x01]),
[0x13, 0x01]
None
);
}
#[test]
fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offered() {
let key_share = client_key_share_extension(&[
(0x0a0a, 1),
(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
),
(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN),
]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(
select_server_hello_key_share_group(&ch),
Some(TLS_NAMED_GROUP_X25519MLKEM768)
);
}
#[test]
fn select_server_hello_key_share_group_rejects_without_hybrid_share() {
let key_share =
client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_hybrid_len() {
let key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN - 1,
)]);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn select_server_hello_key_share_group_rejects_malformed_key_share_tail() {
let mut key_share = client_key_share_extension(&[(
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
)]);
let shares_len = u16::from_be_bytes([key_share[0], key_share[1]]) + 1;
key_share[0..2].copy_from_slice(&shares_len.to_be_bytes());
key_share.push(0);
let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com");
assert_eq!(select_server_hello_key_share_group(&ch), None);
}
#[test]
fn extract_sni_rejects_zero_length_host_name() {
let mut sni_ext = Vec::new();

View File

@@ -109,11 +109,22 @@ mod cipher_suite {
pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03];
}
/// TLS Named Curves
/// TLS named groups used in KeyShare extensions.
mod named_curve {
pub const X25519: u16 = 0x001d;
pub const X25519MLKEM768: u16 = 0x11ec;
}
/// TLS X25519 named group.
pub(crate) const TLS_NAMED_GROUP_X25519: u16 = named_curve::X25519;
/// TLS X25519MLKEM768 named group.
pub(crate) const TLS_NAMED_GROUP_X25519MLKEM768: u16 = named_curve::X25519MLKEM768;
const X25519_KEY_SHARE_LEN: usize = 32;
const X25519MLKEM768_CLIENT_KEY_SHARE_LEN: usize = 1216;
const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120;
const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088;
// ============= TLS Validation Result =============
/// Result of validating TLS handshake
@@ -144,26 +155,28 @@ impl TlsExtensionBuilder {
}
}
/// Add Key Share extension with X25519 key
fn add_key_share(&mut self, public_key: &[u8; 32]) -> &mut Self {
/// Add KeyShare extension with the selected named group.
fn add_key_share(&mut self, group: u16, key_exchange: &[u8]) -> &mut Self {
let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else {
return self;
};
let Some(entry_len) = key_exchange.len().checked_add(4) else {
return self;
};
let Ok(entry_len) = u16::try_from(entry_len) else {
return self;
};
// Extension type: key_share (0x0033)
self.extensions
.extend_from_slice(&extension_type::KEY_SHARE.to_be_bytes());
// Key share entry: curve (2) + key_len (2) + key (32) = 36 bytes
// Extension data length
let entry_len: u16 = 2 + 2 + 32; // curve + length + key
// ServerHello key_share data is exactly one KeyShareEntry.
self.extensions.extend_from_slice(&entry_len.to_be_bytes());
// Named curve: x25519
self.extensions.extend_from_slice(&group.to_be_bytes());
self.extensions
.extend_from_slice(&named_curve::X25519.to_be_bytes());
// Key length
self.extensions.extend_from_slice(&(32u16).to_be_bytes());
// Key data
self.extensions.extend_from_slice(public_key);
.extend_from_slice(&key_exchange_len.to_be_bytes());
self.extensions.extend_from_slice(key_exchange);
self
}
@@ -232,8 +245,8 @@ impl ServerHelloBuilder {
}
}
fn with_x25519_key(mut self, key: &[u8; 32]) -> Self {
self.extensions.add_key_share(key);
fn with_key_share(mut self, group: u16, key_exchange: &[u8]) -> Self {
self.extensions.add_key_share(group, key_exchange);
self
}
@@ -508,11 +521,22 @@ fn validate_tls_handshake_at_time_with_boot_cap(
/// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint,
/// yielding distribution-consistent public keys for anti-fingerprinting.
pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] {
let mut scalar = [0u8; 32];
scalar.copy_from_slice(&rng.bytes(32));
let mut scalar = [0u8; X25519_KEY_SHARE_LEN];
scalar.copy_from_slice(&rng.bytes(X25519_KEY_SHARE_LEN));
x25519(scalar, X25519_BASEPOINT_BYTES)
}
/// Generate a fake X25519MLKEM768 ServerHello key_share payload.
pub(crate) fn gen_fake_x25519mlkem768_server_key_share(rng: &SecureRandom) -> Vec<u8> {
let mut key_share = vec![0u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN];
// FakeTLS never derives TLS traffic secrets from this payload; only the
// externally visible named group and vector lengths are protocol-facing.
rng.fill(&mut key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]);
let x25519_key = gen_fake_x25519_key(rng);
key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..].copy_from_slice(&x25519_key);
key_share
}
/// Build TLS ServerHello response
///
/// This builds a complete TLS 1.3-like response including:
@@ -560,12 +584,12 @@ pub(crate) fn build_server_hello_with_cipher(
const MIN_APP_DATA: usize = 64;
const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE;
let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA);
let x25519_key = gen_fake_x25519_key(rng);
// Build ServerHello
let key_share = gen_fake_x25519mlkem768_server_key_share(rng);
let server_hello = ServerHelloBuilder::new(session_id.to_vec())
.with_cipher_suite(selected_cipher_suite)
.with_x25519_key(&x25519_key)
.with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key_share)
.with_tls13_version()
.build_record();
@@ -1003,6 +1027,145 @@ fn client_hello_cipher_suites_range(handshake: &[u8]) -> Option<(usize, usize)>
Some((pos, cipher_end))
}
fn client_hello_extensions_range(handshake: &[u8]) -> Option<(usize, usize)> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
let record_end = 5usize.checked_add(record_len)?;
if record_end > handshake.len() {
return None;
}
let mut pos = 5;
if handshake.get(pos) != Some(&0x01) {
return None;
}
pos += 1;
if pos + 3 > record_end {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3;
let handshake_end = pos.checked_add(handshake_len)?;
if handshake_end > record_end {
return None;
}
if pos + 2 + 32 > handshake_end {
return None;
}
pos += 2 + 32;
let session_id_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(session_id_len)?;
if pos + 2 > handshake_end {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
if cipher_len == 0 || cipher_len % 2 != 0 {
return None;
}
pos += 2;
pos = pos.checked_add(cipher_len)?;
if pos + 1 > handshake_end {
return None;
}
let compression_len = *handshake.get(pos)? as usize;
pos = pos.checked_add(1)?.checked_add(compression_len)?;
if pos == handshake_end {
return Some((handshake_end, handshake_end));
}
if pos + 2 > handshake_end {
return None;
}
let extensions_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
pos += 2;
let extensions_end = pos.checked_add(extensions_len)?;
if extensions_end > handshake_end {
return None;
}
Some((pos, extensions_end))
}
fn key_share_extension_has_group(
data: &[u8],
group: u16,
expected_key_exchange_len: usize,
) -> bool {
if data.len() < 2 {
return false;
}
let shares_len = u16::from_be_bytes([data[0], data[1]]) as usize;
if shares_len != data.len().saturating_sub(2) {
return false;
}
let mut pos = 2usize;
let shares_end = 2 + shares_len;
let mut found_group = false;
while pos + 4 <= shares_end {
let entry_group = u16::from_be_bytes([data[pos], data[pos + 1]]);
let key_exchange_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let Some(key_exchange_end) = pos.checked_add(key_exchange_len) else {
return false;
};
if key_exchange_end > shares_end {
return false;
}
if entry_group == group && key_exchange_len == expected_key_exchange_len {
found_group = true;
}
pos = key_exchange_end;
}
found_group && pos == shares_end
}
fn client_hello_offers_key_share_group(
handshake: &[u8],
group: u16,
expected_key_exchange_len: usize,
) -> bool {
let Some((mut pos, extensions_end)) = client_hello_extensions_range(handshake) else {
return false;
};
while pos + 4 <= extensions_end {
let ext_type = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
pos += 4;
let Some(ext_end) = pos.checked_add(ext_len) else {
return false;
};
if ext_end > extensions_end {
return false;
}
if ext_type == extension_type::KEY_SHARE {
return key_share_extension_has_group(
&handshake[pos..ext_end],
group,
expected_key_exchange_len,
);
}
pos = ext_end;
}
false
}
fn client_hello_offers_cipher_suite(
handshake: &[u8],
range: (usize, usize),
@@ -1027,20 +1190,23 @@ fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool {
/// Select the ServerHello cipher suite from the already-received ClientHello.
///
/// This is intentionally a borrowed, zero-allocation scan. It runs only for an
/// authenticated success response and keeps malformed or unexpected ClientHello
/// shapes on the previous fallback behavior.
pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8; 2]) -> [u8; 2] {
/// authenticated success response and fails closed for malformed or unsupported
/// ClientHello shapes that cannot produce a DPI-consistent ServerHello.
pub(crate) fn select_server_hello_cipher_suite(
handshake: &[u8],
preferred: [u8; 2],
) -> Option<[u8; 2]> {
let preferred = if is_tls13_cipher_suite(preferred) {
preferred
} else {
cipher_suite::TLS_AES_128_GCM_SHA256
};
let Some(range) = client_hello_cipher_suites_range(handshake) else {
return preferred;
return None;
};
if client_hello_offers_cipher_suite(handshake, range, preferred) {
return preferred;
return Some(preferred);
}
for fallback in [
@@ -1049,11 +1215,27 @@ pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8;
cipher_suite::TLS_AES_256_GCM_SHA384,
] {
if client_hello_offers_cipher_suite(handshake, range, fallback) {
return fallback;
return Some(fallback);
}
}
preferred
None
}
/// Select the hybrid ServerHello key_share named group from the authenticated ClientHello.
///
/// Malformed or non-hybrid key_share structures fail closed so authenticated
/// but DPI-inconsistent ClientHellos take the ordinary masking fallback path.
pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option<u16> {
if client_hello_offers_key_share_group(
handshake,
TLS_NAMED_GROUP_X25519MLKEM768,
X25519MLKEM768_CLIENT_KEY_SHARE_LEN,
) {
Some(TLS_NAMED_GROUP_X25519MLKEM768)
} else {
None
}
}
/// Check if bytes look like a TLS ClientHello

View File

@@ -1473,24 +1473,22 @@ where
return HandshakeResult::BadClient { reader, writer };
}
let cached = if config.censorship.tls_emulation {
if tls::select_server_hello_key_share_group(handshake).is_none() {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a valid X25519MLKEM768 key_share"
);
return HandshakeResult::BadClient { reader, writer };
}
let cached_entry = if config.censorship.tls_emulation {
if let Some(cache) = tls_cache.as_ref() {
let selected_domain =
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
let cached_entry = cache.get(selected_domain).await;
let use_full_cert_payload = if config.censorship.serverhello_compact
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
{
cache
.take_full_cert_budget_for_ip(
peer.ip(),
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
)
.await
} else {
true
};
Some((cached_entry, use_full_cert_payload))
Some(cached_entry)
} else {
None
}
@@ -1498,19 +1496,54 @@ where
None
};
let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() {
if cached_entry.server_hello_template.cipher_suite == [0, 0] {
[0x13, 0x01]
} else {
cached_entry.server_hello_template.cipher_suite
}
} else {
[0x13, 0x01]
};
let Some(selected_cipher_suite) =
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite)
else {
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
maybe_apply_server_hello_delay(config).await;
debug!(
peer = %peer,
"TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite"
);
return HandshakeResult::BadClient { reader, writer };
};
let cached = if let Some(cached_entry) = cached_entry {
let use_full_cert_payload = if config.censorship.serverhello_compact
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
{
if let Some(cache) = tls_cache.as_ref() {
cache.take_full_cert_budget_for_ip(
peer.ip(),
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
)
.await
} else {
true
}
} else {
true
};
Some((cached_entry, use_full_cert_payload))
} else {
None
};
// Add replay digest only for policy-valid handshakes.
replay_checker.add_tls_digest(digest_half);
let validation_session_id_slice = &validation_session_id[..validation_session_id_len];
let response = if let Some((cached_entry, use_full_cert_payload)) = cached {
let preferred_cipher_suite = if cached_entry.server_hello_template.cipher_suite == [0, 0] {
[0x13, 0x01]
} else {
cached_entry.server_hello_template.cipher_suite
};
let selected_cipher_suite =
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite);
emulator::build_emulated_server_hello(
&validated_secret,
&validation_digest,
@@ -1525,7 +1558,6 @@ where
config.censorship.tls_new_session_tickets,
)
} else {
let selected_cipher_suite = tls::select_server_hello_cipher_suite(handshake, [0x13, 0x01]);
tls::build_server_hello_with_cipher(
&validated_secret,
&validation_digest,

View File

@@ -385,7 +385,7 @@ mod tls_domain_mask_host_tests {
let mut config = ProxyConfig::default();
config.censorship.tls_domain = "a.com".to_string();
config.censorship.tls_domains = vec!["b.com".to_string(), "c.com".to_string()];
config.censorship.mask_host = Some("a.com".to_string());
config.censorship.mask_host = None;
config
}
@@ -419,6 +419,15 @@ mod tls_domain_mask_host_tests {
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com");
}
#[test]
fn mask_host_uses_primary_domain_when_dynamic_masking_is_disabled() {
let mut config = config_with_tls_domains();
config.censorship.mask_dynamic = false;
let initial_data = client_hello_with_sni("b.com");
assert_eq!(mask_host_for_initial_data(&config, &initial_data), "a.com");
}
#[test]
fn exclusive_mask_target_overrides_only_matching_sni() {
let mut config = config_with_tls_domains();
@@ -577,24 +586,32 @@ fn default_mask_tcp_target_for_initial_data<'a>(
.as_deref()
.unwrap_or(&config.censorship.tls_domain);
if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) {
if config.censorship.mask_host.is_none() && config.censorship.mask_dynamic {
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
if let Some(host) = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
{
return MaskTcpTarget {
host,
port: config.censorship.mask_port,
};
}
}
if let Some(mask_host) = config.censorship.mask_host.as_deref() {
return MaskTcpTarget {
host: configured_mask_host,
host: mask_host,
port: config.censorship.mask_port,
};
}
let extracted_sni = if sni.is_none() {
tls::extract_sni_from_client_hello(initial_data)
} else {
None
};
let host = sni
.or(extracted_sni.as_deref())
.and_then(|sni| matching_tls_domain_for_sni(config, sni))
.unwrap_or(configured_mask_host);
MaskTcpTarget {
host,
host: configured_mask_host,
port: config.censorship.mask_port,
}
}

View File

@@ -6,7 +6,8 @@ use crate::protocol::constants::{
TLS_RECORD_HANDSHAKE, TLS_VERSION,
};
use crate::protocol::tls::{
ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key,
ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519MLKEM768,
gen_fake_x25519mlkem768_server_key_share,
};
use crate::tls_front::types::{
CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource,
@@ -196,13 +197,27 @@ fn push_supported_versions_extension(extensions: &mut Vec<u8>) {
extensions.extend_from_slice(&0x0304u16.to_be_bytes());
}
fn push_key_share_extension(extensions: &mut Vec<u8>, rng: &SecureRandom) {
let key = gen_fake_x25519_key(rng);
fn push_key_share_entry(extensions: &mut Vec<u8>, group: u16, key_exchange: &[u8]) {
let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else {
return;
};
let Some(entry_len) = key_exchange.len().checked_add(4) else {
return;
};
let Ok(entry_len) = u16::try_from(entry_len) else {
return;
};
extensions.extend_from_slice(&EXT_KEY_SHARE.to_be_bytes());
extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes());
extensions.extend_from_slice(&0x001du16.to_be_bytes());
extensions.extend_from_slice(&(32u16).to_be_bytes());
extensions.extend_from_slice(&key);
extensions.extend_from_slice(&entry_len.to_be_bytes());
extensions.extend_from_slice(&group.to_be_bytes());
extensions.extend_from_slice(&key_exchange_len.to_be_bytes());
extensions.extend_from_slice(key_exchange);
}
fn push_key_share_extension(extensions: &mut Vec<u8>, rng: &SecureRandom) {
let key = gen_fake_x25519mlkem768_server_key_share(rng);
push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519MLKEM768, &key);
}
fn replay_profiled_server_hello_extension(

View File

@@ -9,7 +9,7 @@ use std::io::Result;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::net::TcpStream;
use tracing::debug;
use tracing::{debug, warn};
const DEFAULT_SOCKET_BUFFER_BYTES: usize = 256 * 1024;
@@ -283,6 +283,8 @@ pub struct ListenOptions {
pub backlog: u32,
/// IPv6 only (disable dual-stack)
pub ipv6_only: bool,
/// Client-facing TCP MSS to announce on accepted TCP sessions.
pub client_mss: Option<u16>,
}
impl Default for ListenOptions {
@@ -292,6 +294,7 @@ impl Default for ListenOptions {
reuse_port: true,
backlog: 1024,
ipv6_only: false,
client_mss: None,
}
}
}
@@ -319,6 +322,19 @@ pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result<Sock
socket.set_only_v6(true)?;
}
if let Some(client_mss) = options.client_mss {
if let Err(error) = socket.set_tcp_mss(u32::from(client_mss)) {
warn!(
addr = %addr,
client_mss,
error = %error,
"Failed to apply listener client MSS; continuing with kernel default"
);
} else {
debug!(addr = %addr, client_mss, "Applied listener client MSS");
}
}
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(options.backlog as i32)?;
@@ -637,5 +653,28 @@ mod tests {
assert!(opts.reuse_addr);
assert!(opts.reuse_port);
assert_eq!(opts.backlog, 1024);
assert_eq!(opts.client_mss, None);
}
#[cfg(target_os = "linux")]
#[test]
fn test_create_listener_applies_client_mss() {
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let options = ListenOptions {
reuse_port: false,
client_mss: Some(256),
..Default::default()
};
let socket = match create_listener(addr, &options) {
Ok(socket) => socket,
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
Err(e) => panic!("create_listener failed: {e}"),
};
let mss = match socket.tcp_mss() {
Ok(mss) => mss,
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
Err(e) => panic!("tcp_mss failed: {e}"),
};
assert_eq!(mss, 256);
}
}