feat(crypto,cli,docs): russian SNI palette + RF-billing deployment scenario
Adds a way to make the outer-TLS SNI rotate among popular Russian-language
domains so that Russian carriers — who may start metering "foreign traffic"
separately — see the user's first hop as a domestic CDN/site request, not
as an exotic foreign destination.
- aura-crypto::masks:
- SNI_PALETTE_RUSSIAN (15 real domains: mail.yandex.ru, vk.com, www.ozon.ru,
dzen.ru, ya.ru, www.gosuslugi.ru, www.wildberries.ru, rutube.ru,
news.rambler.ru, hh.ru, www.tinkoff.ru, lenta.ru, www.kinopoisk.ru,
afisha.yandex.ru, music.yandex.ru).
- enum SniPalette { Default, Russian, Mixed } (Default = v2 behavior).
- derive_mask_for_msk_date_with_palette(...): pick from chosen palette,
Mixed flips ~50/50 by HKDF okm[8]&1. Old derive_mask_for_msk_date kept
as a thin wrapper -> byte-for-byte unchanged Default.
- aura-cli::masks::MaskRotator gains new_with_palette(...); the spawn loop
uses the stored palette. Old new() preserves Default.
- aura-cli config: [transport.masks] palette = "default"|"russian"|"mixed"
(serde rename_all = "lowercase", default Default).
- server.rs/client.rs read cfg.transport.masks.palette and pass it to the
rotator at startup; logged at INFO so the operator sees the choice.
- docs/deployment.md: new §7 "Сервер в РФ против тарификации иностранного
трафика" — context, ASCII topology, recommended RF providers, full
server.toml + client.toml examples wiring [server.relay] + russian
palette + LE outer cert + multi-hop, plus an honest list of what this
does and does not give.
- config/{server,client}.toml.example updated with palette = "default".
Workspace: 284 tests passed (+8 new = 4 crypto + 2 cli masks + 2 config),
clippy -D warnings clean, fmt clean. 276 baseline tests untouched.
Backward-compatible: configs without palette default to Default, identical
to v2 wire behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -721,17 +721,64 @@ impl Default for CoverSection {
|
||||
/// Both peers derive the current mask from `(CA fingerprint, MSK date)`; no wire coordination is
|
||||
/// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` /
|
||||
/// `[mimicry] sni` are used as before (pre-rotation behaviour).
|
||||
///
|
||||
/// v3.2 adds the `palette` field, which selects the SNI palette the rotator picks from:
|
||||
///
|
||||
/// * `"default"` (back-compat with every pre-v3.2 deployment) — global CDN-like names.
|
||||
/// * `"russian"` — top Russian domains (use when the SNI should look domestic to a Russian ISP).
|
||||
/// * `"mixed"` — alternates between the two palettes day-to-day under HKDF control.
|
||||
///
|
||||
/// Server and client MUST agree on the palette only if both sides want their SNIs to match — the
|
||||
/// SNI is per-connection on the wire and the server does not advertise an SNI itself, so a
|
||||
/// mismatch is not an error; it just means the client and the server log slightly different "today's
|
||||
/// SNI" hostnames. Recommended: pick the same palette on both ends.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MasksSection {
|
||||
/// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`:
|
||||
/// use the static TOML values verbatim.
|
||||
pub enabled: bool,
|
||||
/// v3.2: which SNI palette the daily rotator picks from. Default `"default"` (the pre-v3.2
|
||||
/// CDN palette).
|
||||
pub palette: MaskPalette,
|
||||
}
|
||||
|
||||
impl Default for MasksSection {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
Self {
|
||||
enabled: true,
|
||||
palette: MaskPalette::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// v3.2: selector for [`MasksSection::palette`] — which SNI palette the daily rotator draws from.
|
||||
///
|
||||
/// Mirror of [`aura_crypto::SniPalette`]. Kept as a separate type so the TOML parser does not need
|
||||
/// to depend on the crypto crate's enum and so the back-compat default lives next to the config.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MaskPalette {
|
||||
/// Global CDN palette (pre-v3.2 default).
|
||||
#[default]
|
||||
Default,
|
||||
/// Top Russian domains. Use when the SNI should look like ordinary HTTPS to a large Russian
|
||||
/// site (typical case: an entry-relay on a Russian VPS for the domestic-traffic deployment
|
||||
/// documented in `docs/deployment.md`).
|
||||
Russian,
|
||||
/// Mixed: HKDF picks Default vs Russian per day.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
impl MaskPalette {
|
||||
/// Lift the TOML enum into the corresponding [`aura_crypto::SniPalette`] variant.
|
||||
#[must_use]
|
||||
pub fn to_crypto(self) -> aura_crypto::SniPalette {
|
||||
match self {
|
||||
Self::Default => aura_crypto::SniPalette::Default,
|
||||
Self::Russian => aura_crypto::SniPalette::Russian,
|
||||
Self::Mixed => aura_crypto::SniPalette::Mixed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1870,6 +1917,111 @@ pool_cidr = "10.7.0.0/24"
|
||||
assert!(cfg.server.nat.is_none(), "nat section absent by default");
|
||||
}
|
||||
|
||||
/// v3.2: `[transport.masks] palette = "russian"` parses into [`MaskPalette::Russian`] and
|
||||
/// maps to [`aura_crypto::SniPalette::Russian`]. The other two values round-trip the same way.
|
||||
#[test]
|
||||
fn parses_mask_palette_russian() {
|
||||
let s = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
[transport.masks]
|
||||
enabled = true
|
||||
palette = "russian"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s).expect("parse server.toml with russian palette");
|
||||
assert!(cfg.transport.masks.enabled);
|
||||
assert_eq!(cfg.transport.masks.palette, MaskPalette::Russian);
|
||||
assert_eq!(
|
||||
cfg.transport.masks.palette.to_crypto(),
|
||||
aura_crypto::SniPalette::Russian
|
||||
);
|
||||
|
||||
// Other accepted values: "default" and "mixed".
|
||||
for (raw, expected, crypto) in [
|
||||
(
|
||||
"default",
|
||||
MaskPalette::Default,
|
||||
aura_crypto::SniPalette::Default,
|
||||
),
|
||||
("mixed", MaskPalette::Mixed, aura_crypto::SniPalette::Mixed),
|
||||
] {
|
||||
let s = format!(
|
||||
r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
[transport.masks]
|
||||
palette = "{raw}"
|
||||
"#
|
||||
);
|
||||
let cfg = ServerConfigFile::parse(&s).expect("parse server.toml");
|
||||
assert_eq!(cfg.transport.masks.palette, expected, "palette = {raw}");
|
||||
assert_eq!(cfg.transport.masks.palette.to_crypto(), crypto);
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat: omitting `[transport.masks] palette` falls back to [`MaskPalette::Default`] so
|
||||
/// every pre-v3.2 config keeps its behaviour byte-for-byte.
|
||||
#[test]
|
||||
fn mask_palette_defaults_when_omitted() {
|
||||
// Server side: no [transport.masks] block at all.
|
||||
let s_server = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s_server).expect("parse minimal server.toml");
|
||||
assert_eq!(cfg.transport.masks.palette, MaskPalette::Default);
|
||||
// Server side: section present but no `palette` key.
|
||||
let s_server_partial = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
[transport.masks]
|
||||
enabled = true
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s_server_partial)
|
||||
.expect("parse server.toml with masks but no palette");
|
||||
assert_eq!(cfg.transport.masks.palette, MaskPalette::Default);
|
||||
|
||||
// Client side: same checks.
|
||||
let c_client = r#"
|
||||
[client]
|
||||
name = "x"
|
||||
server_addr = "1.2.3.4:443"
|
||||
sni = "a"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
"#;
|
||||
let cfg = ClientConfigFile::parse(c_client).expect("parse minimal client.toml");
|
||||
assert_eq!(cfg.transport.masks.palette, MaskPalette::Default);
|
||||
}
|
||||
|
||||
/// `run_as` is parsed off both [server] and [client] sections and is optional.
|
||||
#[test]
|
||||
fn parses_run_as_on_both_configs() {
|
||||
|
||||
Reference in New Issue
Block a user