sync 2026-05-27T13:23:42Z

This commit is contained in:
Afgroup 2026-05-27 16:23:42 +03:00
parent 789c6aff65
commit 614381d896
22 changed files with 2279 additions and 416 deletions

View File

@ -66,3 +66,6 @@ External-Audit/critic-analysis-*.md
# local docs/ — symlinks to English specs for unified browsing
/docs/
# Internal ops/role docs — contain IPs and secret key material, never publish
_internal-private/

View File

@ -1858,6 +1858,14 @@ dependencies = [
"serde_json",
]
[[package]]
name = "mt-egress"
version = "1.0.0"
dependencies = [
"mt-codec",
"mt-net",
]
[[package]]
name = "mt-entry"
version = "1.0.0"

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["crates/mt-account", "crates/mt-codec", "crates/mt-conformance", "crates/mt-consensus", "crates/mt-crypto", "crates/mt-crypto-native", "crates/mt-entry", "crates/mt-examples", "crates/mt-genesis", "crates/montana-node", "crates/mt-lottery", "crates/mt-merkle", "crates/mt-mnemonic", "crates/mt-net", "crates/mt-net-transport", "crates/mt-noise-pq", "crates/mt-timechain", "crates/mt-state",
"crates/mt-sync", "crates/mt-store", "crates/mt-vpn-balance", "crates/mt-bindings"]
"crates/mt-sync", "crates/mt-store", "crates/mt-vpn-balance", "crates/mt-bindings", "crates/mt-egress"]
[workspace.package]
version = "1.0.0"

View File

@ -1,12 +1,13 @@
# Version
**Implementation:** `1.0.0` (mainnet — M1..M6 + M9 audit-ready; Noise_PQ XX is the production transport across the four-node Genesis + Yerevan mesh; M7 fast-sync snapshot mechanism live; DEV-012 Phase B+C and M7 client-side handler carried into v1.0.1 hot-fix track)
**Spec target:** Montana Protocol v35.25.1 + Montana Network v1.2.0 + Montana App v3.12.0 + Montana Egress v1.0.0 (2026-05-26)
**Spec target:** Montana Protocol v35.25.1 + Montana Network v1.2.0 + Montana App v3.12.0 + Montana Egress v1.0.0 + Montana VPN Alliance v1.1.0 (2026-05-26)
**Release tag:** v1.0.0 (2026-05-22) — first mainnet release
**Spec paths:**
- Protocol: `../Montana Protocol v35.25.1.md`
- Network: `../Montana Network v1.2.0.md`
- Egress: `../Montana Egress v1.0.0.md`
- Alliance: `../Montana VPN Alliance v1.1.0.md`
- App: `../Montana App v3.12.0.md`
## Policy

View File

@ -7,7 +7,9 @@ use core::slice;
use std::ffi::CStr;
use std::os::raw::{c_char, c_int};
use mt_crypto::{keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature};
use mt_crypto::{
keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature,
};
use mt_mnemonic::{mldsa_seed_for_role, mnemonic_to_master_seed};
use mt_state::derive_account_id;
@ -39,7 +41,7 @@ pub unsafe extern "C" fn mt_mnemonic_to_master_seed(
slice::from_raw_parts_mut(out_master_seed, MT_MASTER_SEED_LEN)
.copy_from_slice(&seed);
MT_OK
}
},
Err(e) => match e {
mt_mnemonic::MnemonicError::WordCount(_) => MT_ERR_MNEMONIC_WORD_COUNT,
mt_mnemonic::MnemonicError::UnknownWord(_) => MT_ERR_MNEMONIC_UNKNOWN_WORD,
@ -88,7 +90,7 @@ pub unsafe extern "C" fn mt_mldsa_keypair_from_seed(
slice::from_raw_parts_mut(out_seckey, MT_MLDSA_SECKEY_SIZE)
.copy_from_slice(sk.as_bytes());
MT_OK
}
},
Err(_) => MT_ERR_KEYGEN_FAILED,
}
})
@ -160,13 +162,17 @@ pub unsafe extern "C" fn mt_sign(
Some(k) => k,
None => return MT_ERR_SIGN_FAILED,
};
let m: &[u8] = if msg_len == 0 { &[] } else { slice::from_raw_parts(msg, msg_len) };
let m: &[u8] = if msg_len == 0 {
&[]
} else {
slice::from_raw_parts(msg, msg_len)
};
match mldsa_sign(&sk, m) {
Ok(sig) => {
slice::from_raw_parts_mut(out_sig, MT_MLDSA_SIG_SIZE)
.copy_from_slice(sig.as_bytes());
MT_OK
}
},
Err(_) => MT_ERR_SIGN_FAILED,
}
})
@ -193,8 +199,16 @@ pub unsafe extern "C" fn mt_verify(
Some(s) => s,
None => return MT_ERR_VERIFY_FAILED,
};
let m: &[u8] = if msg_len == 0 { &[] } else { slice::from_raw_parts(msg, msg_len) };
if mldsa_verify(&pk, m, &signature) { MT_OK } else { MT_ERR_VERIFY_FAILED }
let m: &[u8] = if msg_len == 0 {
&[]
} else {
slice::from_raw_parts(msg, msg_len)
};
if mldsa_verify(&pk, m, &signature) {
MT_OK
} else {
MT_ERR_VERIFY_FAILED
}
})
}

View File

@ -13,7 +13,9 @@ use jni::sys::{jbyteArray, jint};
use jni::JNIEnv;
use mt_codec::domain;
use mt_crypto::{keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature};
use mt_crypto::{
keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature,
};
use mt_mnemonic::{entropy_to_mnemonic, mldsa_seed_for_role, mnemonic_to_master_seed};
use mt_state::derive_account_id;
@ -96,8 +98,9 @@ pub extern "system" fn Java_quest_montana_app_MtBindings_nativeAccountFromMnemon
};
let account_id = derive_account_id(MT_SUITE_MLDSA65, pk.as_bytes());
let mut buf =
Vec::with_capacity(super::MT_MLDSA_PUBKEY_SIZE + super::MT_MLDSA_SECKEY_SIZE + super::MT_ACCOUNT_ID_LEN);
let mut buf = Vec::with_capacity(
super::MT_MLDSA_PUBKEY_SIZE + super::MT_MLDSA_SECKEY_SIZE + super::MT_ACCOUNT_ID_LEN,
);
buf.extend_from_slice(pk.as_bytes());
buf.extend_from_slice(sk.as_bytes());
buf.extend_from_slice(&account_id);
@ -168,5 +171,9 @@ pub extern "system" fn Java_quest_montana_app_MtBindings_nativeVerify<'local>(
Some(s) => s,
None => return -1,
};
if mldsa_verify(&pk, &m, &signature) { 1 } else { 0 }
if mldsa_verify(&pk, &m, &signature) {
1
} else {
0
}
}

View File

@ -6,7 +6,9 @@
use wasm_bindgen::prelude::*;
use mt_crypto::{keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature};
use mt_crypto::{
keypair_from_seed, sign as mldsa_sign, verify as mldsa_verify, PublicKey, SecretKey, Signature,
};
use mt_mnemonic::{mldsa_seed_for_role, mnemonic_to_master_seed};
use mt_state::derive_account_id;
@ -30,11 +32,13 @@ pub fn account_from_mnemonic(mnemonic: &str) -> Result<Vec<u8>, JsValue> {
let master = mnemonic_to_master_seed(mnemonic)
.map_err(|e| JsValue::from_str(&format!("mnemonic: {e:?}")))?;
let acc_seed = mldsa_seed_for_role(&master, mt_codec::domain::ACCOUNT_KEY);
let (pk, sk) = keypair_from_seed(&acc_seed)
.map_err(|e| JsValue::from_str(&format!("keygen: {e:?}")))?;
let (pk, sk) =
keypair_from_seed(&acc_seed).map_err(|e| JsValue::from_str(&format!("keygen: {e:?}")))?;
let account_id = derive_account_id(MT_SUITE_MLDSA65, pk.as_bytes());
let mut out = Vec::with_capacity(super::MT_MLDSA_PUBKEY_SIZE + super::MT_MLDSA_SECKEY_SIZE + super::MT_ACCOUNT_ID_LEN);
let mut out = Vec::with_capacity(
super::MT_MLDSA_PUBKEY_SIZE + super::MT_MLDSA_SECKEY_SIZE + super::MT_ACCOUNT_ID_LEN,
);
out.extend_from_slice(pk.as_bytes());
out.extend_from_slice(sk.as_bytes());
out.extend_from_slice(&account_id);

View File

@ -0,0 +1,11 @@
[package]
name = "mt-egress"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
mt-codec = { path = "../mt-codec" }
mt-net = { path = "../mt-net" }

View File

@ -0,0 +1,156 @@
// Egress client-side exit selection.
// spec: Montana Egress v1.0.0 (Session establishment, step 2 — Exit selection).
//
// Manual: the directory entry whose country_code equals the chosen country.
// Auto: the reachable exit ranked highest by the reachability map for the
// client's vantage (Network -> Reachability sensing). Exit selection is
// performed by the client, never dictated by the entry; the chosen exit
// is confirmed by a direct IBT handshake before any egress.
use alloc::vec::Vec;
use mt_net::ReachabilityMap;
use crate::EgressDirectoryEntry;
/// Exit selection mode requested by the client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitSelector {
/// Manual: exit in the given ISO-3166-1 alpha-2 jurisdiction.
Manual([u8; 2]),
/// Auto: highest-reachability exit for the client's vantage.
Auto,
}
/// Select an exit node_id from the advisory directory.
///
/// Manual mode picks the highest-capacity entry whose country matches.
/// Auto mode ranks the directory's exits by the reachability map for the
/// client's (country_code, asn) vantage and picks the top; with no actionable
/// map data it falls back to the highest-capacity entry. Returns None when no
/// candidate satisfies the request.
pub fn select_exit(
directory: &[EgressDirectoryEntry],
selector: &ExitSelector,
map: &ReachabilityMap,
vantage_country: [u8; 2],
vantage_asn: u32,
) -> Option<[u8; 32]> {
match selector {
ExitSelector::Manual(country) => directory
.iter()
.filter(|e| &e.country_code == country)
.max_by_key(|e| e.capacity_class)
.map(|e| e.exit_node_id),
ExitSelector::Auto => {
let candidates: Vec<[u8; 32]> = directory.iter().map(|e| e.exit_node_id).collect();
if candidates.is_empty() {
return None;
}
let ranked = map.steer(&candidates, vantage_country, vantage_asn);
// steer() returns reachability-ranked first, then unranked in input
// order; if the map had any actionable data the head is the best
// reachable exit. Fall back to highest capacity when the head is an
// unranked candidate (map empty).
let head = ranked.first().copied();
match head {
Some(id) if directory.iter().any(|e| e.exit_node_id == id) => {
// prefer reachability head; if map was empty steer preserves
// input order, so refine to highest capacity for determinism
if map.is_empty() {
directory
.iter()
.max_by_key(|e| e.capacity_class)
.map(|e| e.exit_node_id)
} else {
Some(id)
}
},
_ => directory
.iter()
.max_by_key(|e| e.capacity_class)
.map(|e| e.exit_node_id),
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: u8, cc: &[u8; 2], cap: u8) -> EgressDirectoryEntry {
EgressDirectoryEntry {
exit_node_id: [id; 32],
country_code: *cc,
capacity_class: cap,
advertised_window: 20160,
}
}
#[test]
fn manual_picks_matching_country_highest_capacity() {
let dir = [
entry(1, b"FR", 1),
entry(2, b"DE", 2),
entry(3, b"FR", 2), // higher capacity FR
];
let map = ReachabilityMap::new();
let pick = select_exit(&dir, &ExitSelector::Manual(*b"FR"), &map, *b"AM", 1);
assert_eq!(pick, Some([3u8; 32]));
}
#[test]
fn manual_none_when_country_absent() {
let dir = [entry(1, b"FR", 1)];
let map = ReachabilityMap::new();
assert_eq!(
select_exit(&dir, &ExitSelector::Manual(*b"US"), &map, *b"AM", 1),
None
);
}
#[test]
fn auto_empty_directory_none() {
let dir: [EgressDirectoryEntry; 0] = [];
let map = ReachabilityMap::new();
assert_eq!(
select_exit(&dir, &ExitSelector::Auto, &map, *b"AM", 1),
None
);
}
#[test]
fn auto_falls_back_to_capacity_when_map_empty() {
let dir = [entry(1, b"FR", 0), entry(2, b"DE", 2), entry(3, b"US", 1)];
let map = ReachabilityMap::new();
// map empty -> highest capacity (id 2)
assert_eq!(
select_exit(&dir, &ExitSelector::Auto, &map, *b"AM", 1),
Some([2u8; 32])
);
}
#[test]
fn auto_prefers_reachability_head() {
use mt_net::ReachabilityAdvert;
let dir = [entry(1, b"FR", 2), entry(2, b"DE", 0)];
let mut map = ReachabilityMap::new();
// make exit id 2 highly reachable from vantage AM/asn=1 across 3 /16
let mk = |target: [u8; 32], n: u16, d: u16| ReachabilityAdvert {
country_code: *b"AM",
asn: 1,
target_ref: target,
profile: 0,
reachable_num: n,
reachable_den: d,
observed_window: 20160,
};
for src in [[1u8, 0], [2, 0], [3, 0]] {
map.ingest(mk([2u8; 32], 9, 10), src, 20160, 100); // DE exit, 0.9
}
// Auto should pick id 2 (reachable) over higher-capacity id 1 (no map data)
let pick = select_exit(&dir, &ExitSelector::Auto, &map, *b"AM", 1);
assert_eq!(pick, Some([2u8; 32]));
}
}

View File

@ -0,0 +1,258 @@
// Egress exit-side session state machine + policy gate.
// spec: Montana Egress v1.0.0 (Exit node; Control messages invariants).
//
// Pure control logic, outside consensus state. The IBT level-3 proof in
// EgressAuth is verified by the caller via mt_net::ibt::ibt_online_verify with
// server_node_id = exit_node_id (the inner session terminates at the exit), so
// a proof for one exit is invalid at any other node. This module tracks the
// authenticated flag, the open-stream set, and applies the egress policy.
use alloc::collections::BTreeSet;
use crate::{EgressAddr, MAX_STREAMS_PER_SESSION};
/// Result of an EgressOpen request, mapping to EgressOpenAck.status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenOutcome {
Open = 0,
RefusedByPolicy = 1,
Unreachable = 2,
RateLimited = 3,
}
impl OpenOutcome {
pub fn status(self) -> u8 {
self as u8
}
}
/// Operator egress policy. `default_allow` sets the base decision; `port_exceptions`
/// inverts it for the listed destination ports.
#[derive(Debug, Clone)]
pub struct ExitPolicy {
pub default_allow: bool,
pub port_exceptions: BTreeSet<u16>,
}
impl ExitPolicy {
pub fn default_allow() -> Self {
ExitPolicy {
default_allow: true,
port_exceptions: BTreeSet::new(),
}
}
pub fn default_deny() -> Self {
ExitPolicy {
default_allow: false,
port_exceptions: BTreeSet::new(),
}
}
pub fn with_exception(mut self, port: u16) -> Self {
self.port_exceptions.insert(port);
self
}
/// True when the destination port is permitted.
pub fn allows(&self, _addr: &EgressAddr, port: u16) -> bool {
let listed = self.port_exceptions.contains(&port);
if self.default_allow {
!listed
} else {
listed
}
}
}
/// Error from applying a control message to the exit session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitSessionError {
/// Open / Data / Close arrived before a verified EgressAuth (session MUST close).
NotAuthenticated,
/// Data / Close referenced an unknown stream_id.
UnknownStream,
/// Duplicate stream_id on Open.
DuplicateStream,
}
/// Exit-side per-inner-session state.
#[derive(Debug)]
pub struct ExitSession {
authed: bool,
streams: BTreeSet<u32>,
policy: ExitPolicy,
}
impl ExitSession {
pub fn new(policy: ExitPolicy) -> Self {
ExitSession {
authed: false,
streams: BTreeSet::new(),
policy,
}
}
/// Mark the session authenticated. The caller invokes this only after
/// mt_net::ibt::ibt_online_verify succeeds against this exit's node_id.
pub fn authenticate(&mut self) {
self.authed = true;
}
pub fn is_authenticated(&self) -> bool {
self.authed
}
pub fn open_stream_count(&self) -> usize {
self.streams.len()
}
pub fn has_stream(&self, stream_id: u32) -> bool {
self.streams.contains(&stream_id)
}
/// Apply an EgressOpen. Returns the outcome to encode into EgressOpenAck.
pub fn handle_open(
&mut self,
stream_id: u32,
addr: &EgressAddr,
dest_port: u16,
) -> Result<OpenOutcome, ExitSessionError> {
if !self.authed {
return Err(ExitSessionError::NotAuthenticated);
}
if self.streams.contains(&stream_id) {
return Err(ExitSessionError::DuplicateStream);
}
if self.streams.len() as u32 >= MAX_STREAMS_PER_SESSION {
return Ok(OpenOutcome::RateLimited);
}
if !self.policy.allows(addr, dest_port) {
return Ok(OpenOutcome::RefusedByPolicy);
}
self.streams.insert(stream_id);
Ok(OpenOutcome::Open)
}
/// Apply an EgressClose. Removes the stream.
pub fn handle_close(&mut self, stream_id: u32) -> Result<(), ExitSessionError> {
if !self.authed {
return Err(ExitSessionError::NotAuthenticated);
}
if !self.streams.remove(&stream_id) {
return Err(ExitSessionError::UnknownStream);
}
Ok(())
}
/// Validate an EgressData stream reference (payload forwarding is I/O glue).
pub fn check_data(&self, stream_id: u32) -> Result<(), ExitSessionError> {
if !self.authed {
return Err(ExitSessionError::NotAuthenticated);
}
if !self.streams.contains(&stream_id) {
return Err(ExitSessionError::UnknownStream);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_before_auth_rejected() {
let mut s = ExitSession::new(ExitPolicy::default_allow());
let r = s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 443);
assert_eq!(r, Err(ExitSessionError::NotAuthenticated));
}
#[test]
fn auth_then_open_allowed_port() {
let mut s = ExitSession::new(ExitPolicy::default_allow());
s.authenticate();
assert_eq!(
s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 443),
Ok(OpenOutcome::Open)
);
assert!(s.has_stream(1));
assert_eq!(s.open_stream_count(), 1);
}
#[test]
fn policy_default_allow_denies_listed_port() {
let mut s = ExitSession::new(ExitPolicy::default_allow().with_exception(25));
s.authenticate();
assert_eq!(
s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 25),
Ok(OpenOutcome::RefusedByPolicy)
);
assert!(!s.has_stream(1));
assert_eq!(
s.handle_open(2, &EgressAddr::V4([1, 1, 1, 1]), 443),
Ok(OpenOutcome::Open)
);
}
#[test]
fn policy_default_deny_allows_only_listed() {
let mut s = ExitSession::new(ExitPolicy::default_deny().with_exception(443));
s.authenticate();
assert_eq!(
s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 80),
Ok(OpenOutcome::RefusedByPolicy)
);
assert_eq!(
s.handle_open(2, &EgressAddr::V4([1, 1, 1, 1]), 443),
Ok(OpenOutcome::Open)
);
}
#[test]
fn max_streams_rate_limited() {
let mut s = ExitSession::new(ExitPolicy::default_allow());
s.authenticate();
for i in 0..MAX_STREAMS_PER_SESSION {
assert_eq!(
s.handle_open(i, &EgressAddr::V4([1, 1, 1, 1]), 443),
Ok(OpenOutcome::Open)
);
}
assert_eq!(s.open_stream_count(), MAX_STREAMS_PER_SESSION as usize);
assert_eq!(
s.handle_open(MAX_STREAMS_PER_SESSION, &EgressAddr::V4([1, 1, 1, 1]), 443),
Ok(OpenOutcome::RateLimited)
);
}
#[test]
fn close_removes_and_unknown_errors() {
let mut s = ExitSession::new(ExitPolicy::default_allow());
s.authenticate();
s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 443)
.unwrap();
assert!(s.check_data(1).is_ok());
assert_eq!(s.handle_close(1), Ok(()));
assert!(!s.has_stream(1));
assert_eq!(s.handle_close(1), Err(ExitSessionError::UnknownStream));
assert_eq!(s.check_data(1), Err(ExitSessionError::UnknownStream));
}
#[test]
fn duplicate_stream_rejected() {
let mut s = ExitSession::new(ExitPolicy::default_allow());
s.authenticate();
s.handle_open(1, &EgressAddr::V4([1, 1, 1, 1]), 443)
.unwrap();
assert_eq!(
s.handle_open(1, &EgressAddr::V4([2, 2, 2, 2]), 80),
Err(ExitSessionError::DuplicateStream)
);
}
#[test]
fn open_outcome_status_codes() {
assert_eq!(OpenOutcome::Open.status(), 0);
assert_eq!(OpenOutcome::RefusedByPolicy.status(), 1);
assert_eq!(OpenOutcome::Unreachable.status(), 2);
assert_eq!(OpenOutcome::RateLimited.status(), 3);
}
}

View File

@ -0,0 +1,477 @@
// Montana Egress layer — wire codecs.
// spec: Montana Egress v1.0.0 (Egress directory + Control messages).
//
// Application layer above Network. Defines no consensus state; the inner and
// outer transport sessions reuse Noise_PQ XX from the Network layer.
#![cfg_attr(not(test), no_std)]
extern crate alloc;
use alloc::vec::Vec;
pub mod exit;
pub use exit::{ExitPolicy, ExitSession, ExitSessionError, OpenOutcome};
pub mod client;
pub use client::{select_exit, ExitSelector};
pub mod relay;
pub use relay::{RelayBudget, RelayClass, RelayError, CONSENSUS_RELAY_CAP_BYTES_PER_SEC};
use mt_codec::{write_bytes, write_u16, write_u32, write_u8};
use mt_net::NetError;
/// Egress directory entry size: exit_node_id(32) + country_code(2)
/// + capacity_class(1) + advertised_window(4).
pub const EGRESS_DIRECTORY_ENTRY_SIZE: usize = 39;
/// Concurrent open streams an exit honours per inner session.
pub const MAX_STREAMS_PER_SESSION: u32 = 256;
/// Advisory egress directory bound per node.
pub const MAX_DIRECTORY_ENTRIES: usize = 4096;
fn is_iso_alpha(b: u8) -> bool {
b.is_ascii_uppercase()
}
/// Advisory directory advertisement for an opt-in exit node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EgressDirectoryEntry {
pub exit_node_id: [u8; 32],
pub country_code: [u8; 2],
pub capacity_class: u8, // 0 best-effort, 1 standard, 2 high
pub advertised_window: u32,
}
impl EgressDirectoryEntry {
pub fn encode(&self, buf: &mut Vec<u8>) {
write_bytes(buf, &self.exit_node_id);
write_bytes(buf, &self.country_code);
write_u8(buf, self.capacity_class);
write_u32(buf, self.advertised_window);
}
pub fn decode(input: &[u8]) -> Result<Self, NetError> {
if input.len() != EGRESS_DIRECTORY_ENTRY_SIZE {
return Err(NetError::PayloadLengthMismatch);
}
let mut exit_node_id = [0u8; 32];
exit_node_id.copy_from_slice(&input[0..32]);
let mut country_code = [0u8; 2];
country_code.copy_from_slice(&input[32..34]);
let capacity_class = input[34];
let mut win = [0u8; 4];
win.copy_from_slice(&input[35..39]);
let advertised_window = u32::from_le_bytes(win);
if !is_iso_alpha(country_code[0]) || !is_iso_alpha(country_code[1]) {
return Err(NetError::InvalidPayloadField);
}
if capacity_class > 2 {
return Err(NetError::InvalidPayloadField);
}
Ok(EgressDirectoryEntry {
exit_node_id,
country_code,
capacity_class,
advertised_window,
})
}
}
/// Destination address for an egress stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressAddr {
V4([u8; 4]),
V6([u8; 16]),
Host(Vec<u8>), // 1..=255 bytes
}
impl EgressAddr {
fn addr_type(&self) -> u8 {
match self {
EgressAddr::V4(_) => 0,
EgressAddr::V6(_) => 1,
EgressAddr::Host(_) => 2,
}
}
fn encode(&self, buf: &mut Vec<u8>) {
match self {
EgressAddr::V4(a) => write_bytes(buf, a),
EgressAddr::V6(a) => write_bytes(buf, a),
EgressAddr::Host(h) => {
write_u8(buf, h.len() as u8);
write_bytes(buf, h);
},
}
}
}
/// Egress control / data message. Travels over the inner end-to-end session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressControl {
Auth {
proof: Vec<u8>,
},
Open {
stream_id: u32,
protocol: u8,
addr: EgressAddr,
dest_port: u16,
},
OpenAck {
stream_id: u32,
status: u8,
},
Data {
stream_id: u32,
payload: Vec<u8>,
},
Close {
stream_id: u32,
reason: u8,
},
Keepalive,
}
impl EgressControl {
pub fn msg_type(&self) -> u8 {
match self {
EgressControl::Auth { .. } => 0x01,
EgressControl::Open { .. } => 0x02,
EgressControl::OpenAck { .. } => 0x03,
EgressControl::Data { .. } => 0x04,
EgressControl::Close { .. } => 0x05,
EgressControl::Keepalive => 0x06,
}
}
pub fn encode(&self, buf: &mut Vec<u8>) {
write_u8(buf, self.msg_type());
match self {
EgressControl::Auth { proof } => write_bytes(buf, proof),
EgressControl::Open {
stream_id,
protocol,
addr,
dest_port,
} => {
write_u32(buf, *stream_id);
write_u8(buf, *protocol);
write_u8(buf, addr.addr_type());
addr.encode(buf);
write_u16(buf, *dest_port);
},
EgressControl::OpenAck { stream_id, status } => {
write_u32(buf, *stream_id);
write_u8(buf, *status);
},
EgressControl::Data { stream_id, payload } => {
write_u32(buf, *stream_id);
write_bytes(buf, payload);
},
EgressControl::Close { stream_id, reason } => {
write_u32(buf, *stream_id);
write_u8(buf, *reason);
},
EgressControl::Keepalive => {},
}
}
pub fn decode(input: &[u8]) -> Result<Self, NetError> {
if input.is_empty() {
return Err(NetError::TruncatedPayload);
}
let ty = input[0];
let body = &input[1..];
match ty {
0x01 => {
if body.is_empty() {
return Err(NetError::InvalidPayloadField);
}
Ok(EgressControl::Auth {
proof: body.to_vec(),
})
},
0x02 => {
// stream_id(4) protocol(1) addr_type(1) addr(var) dest_port(2)
if body.len() < 8 {
return Err(NetError::TruncatedPayload);
}
let stream_id = rd_u32(&body[0..4]);
let protocol = body[4];
if protocol > 1 {
return Err(NetError::InvalidPayloadField);
}
let addr_type = body[5];
let mut off = 6usize;
let addr = match addr_type {
0 => {
if body.len() < off + 4 + 2 {
return Err(NetError::TruncatedPayload);
}
let mut a = [0u8; 4];
a.copy_from_slice(&body[off..off + 4]);
off += 4;
EgressAddr::V4(a)
},
1 => {
if body.len() < off + 16 + 2 {
return Err(NetError::TruncatedPayload);
}
let mut a = [0u8; 16];
a.copy_from_slice(&body[off..off + 16]);
off += 16;
EgressAddr::V6(a)
},
2 => {
if body.len() < off + 1 {
return Err(NetError::TruncatedPayload);
}
let hlen = body[off] as usize;
off += 1;
if hlen == 0 || body.len() < off + hlen + 2 {
return Err(NetError::InvalidPayloadField);
}
let host = body[off..off + hlen].to_vec();
off += hlen;
EgressAddr::Host(host)
},
_ => return Err(NetError::InvalidPayloadField),
};
if body.len() != off + 2 {
return Err(NetError::PayloadLengthMismatch);
}
let dest_port = rd_u16(&body[off..off + 2]);
Ok(EgressControl::Open {
stream_id,
protocol,
addr,
dest_port,
})
},
0x03 => {
if body.len() != 5 {
return Err(NetError::PayloadLengthMismatch);
}
let stream_id = rd_u32(&body[0..4]);
let status = body[4];
if status > 3 {
return Err(NetError::InvalidPayloadField);
}
Ok(EgressControl::OpenAck { stream_id, status })
},
0x04 => {
if body.len() < 4 {
return Err(NetError::TruncatedPayload);
}
let stream_id = rd_u32(&body[0..4]);
Ok(EgressControl::Data {
stream_id,
payload: body[4..].to_vec(),
})
},
0x05 => {
if body.len() != 5 {
return Err(NetError::PayloadLengthMismatch);
}
let stream_id = rd_u32(&body[0..4]);
let reason = body[4];
if reason > 2 {
return Err(NetError::InvalidPayloadField);
}
Ok(EgressControl::Close { stream_id, reason })
},
0x06 => {
if !body.is_empty() {
return Err(NetError::PayloadLengthMismatch);
}
Ok(EgressControl::Keepalive)
},
other => Err(NetError::InvalidMsgType(other)),
}
}
}
fn rd_u32(b: &[u8]) -> u32 {
let mut a = [0u8; 4];
a.copy_from_slice(&b[0..4]);
u32::from_le_bytes(a)
}
fn rd_u16(b: &[u8]) -> u16 {
let mut a = [0u8; 2];
a.copy_from_slice(&b[0..2]);
u16::from_le_bytes(a)
}
#[cfg(test)]
mod tests {
use super::*;
fn rt(m: &EgressControl) -> EgressControl {
let mut buf = Vec::new();
m.encode(&mut buf);
EgressControl::decode(&buf).unwrap()
}
#[test]
fn directory_roundtrip_and_kat() {
let e = EgressDirectoryEntry {
exit_node_id: [0x22u8; 32],
country_code: *b"FR",
capacity_class: 2,
advertised_window: 0x0000_4ec0,
};
let mut buf = Vec::new();
e.encode(&mut buf);
assert_eq!(buf.len(), EGRESS_DIRECTORY_ENTRY_SIZE);
assert_eq!(EgressDirectoryEntry::decode(&buf).unwrap(), e);
let mut expected = Vec::new();
expected.extend_from_slice(&[0x22u8; 32]);
expected.extend_from_slice(b"FR");
expected.push(2);
expected.extend_from_slice(&[0xc0, 0x4e, 0x00, 0x00]);
assert_eq!(buf, expected);
}
#[test]
fn directory_rejects() {
let mut e = EgressDirectoryEntry {
exit_node_id: [0u8; 32],
country_code: *b"fr",
capacity_class: 0,
advertised_window: 1,
};
let mut buf = Vec::new();
e.encode(&mut buf);
assert!(matches!(
EgressDirectoryEntry::decode(&buf),
Err(NetError::InvalidPayloadField)
));
e.country_code = *b"FR";
e.capacity_class = 3;
let mut buf2 = Vec::new();
e.encode(&mut buf2);
assert!(matches!(
EgressDirectoryEntry::decode(&buf2),
Err(NetError::InvalidPayloadField)
));
}
#[test]
fn control_roundtrips() {
let msgs = [
EgressControl::Auth {
proof: alloc::vec![0xAB; 64],
},
EgressControl::Open {
stream_id: 7,
protocol: 0,
addr: EgressAddr::V4([1, 2, 3, 4]),
dest_port: 443,
},
EgressControl::Open {
stream_id: 8,
protocol: 1,
addr: EgressAddr::V6([9u8; 16]),
dest_port: 53,
},
EgressControl::Open {
stream_id: 9,
protocol: 0,
addr: EgressAddr::Host(b"example.com".to_vec()),
dest_port: 80,
},
EgressControl::OpenAck {
stream_id: 7,
status: 0,
},
EgressControl::Data {
stream_id: 7,
payload: alloc::vec![1, 2, 3, 4, 5],
},
EgressControl::Close {
stream_id: 7,
reason: 2,
},
EgressControl::Keepalive,
];
for m in &msgs {
assert_eq!(&rt(m), m);
}
}
#[test]
fn open_kat_v4() {
let m = EgressControl::Open {
stream_id: 7,
protocol: 0,
addr: EgressAddr::V4([1, 2, 3, 4]),
dest_port: 443,
};
let mut buf = Vec::new();
m.encode(&mut buf);
let mut expected = Vec::new();
expected.push(0x02); // type
expected.extend_from_slice(&[0x07, 0, 0, 0]); // stream_id LE
expected.push(0); // protocol tcp
expected.push(0); // addr_type v4
expected.extend_from_slice(&[1, 2, 3, 4]); // addr
expected.extend_from_slice(&[0xBB, 0x01]); // port 443 LE
assert_eq!(buf, expected);
}
#[test]
fn control_rejects() {
// bad protocol
let m = EgressControl::Open {
stream_id: 1,
protocol: 2,
addr: EgressAddr::V4([0; 4]),
dest_port: 1,
};
let mut b = Vec::new();
m.encode(&mut b);
assert!(matches!(
EgressControl::decode(&b),
Err(NetError::InvalidPayloadField)
));
// bad status
let mut b2 = Vec::new();
EgressControl::OpenAck {
stream_id: 1,
status: 9,
}
.encode(&mut b2);
assert!(matches!(
EgressControl::decode(&b2),
Err(NetError::InvalidPayloadField)
));
// bad reason
let mut b3 = Vec::new();
EgressControl::Close {
stream_id: 1,
reason: 9,
}
.encode(&mut b3);
assert!(matches!(
EgressControl::decode(&b3),
Err(NetError::InvalidPayloadField)
));
// unknown type
assert!(matches!(
EgressControl::decode(&[0x7F, 0, 0]),
Err(NetError::InvalidMsgType(0x7F))
));
// empty
assert!(matches!(
EgressControl::decode(&[]),
Err(NetError::TruncatedPayload)
));
// auth empty proof
assert!(matches!(
EgressControl::decode(&[0x01]),
Err(NetError::InvalidPayloadField)
));
}
}

View File

@ -0,0 +1,134 @@
// Egress relay tier — bandwidth accounting for the front/relay role.
// spec: Montana Egress v1.0.0 (Exit node bandwidth tier) + Montana VPN Alliance
// v1.0.0 (front load model) + Montana Network (Circuit Relay v2).
//
// The egress relay carries the inner Noise_PQ XX session as opaque ciphertext
// (no decryption at the front) and accounts the forwarded bytes against a
// distinct, operator-configured high-bandwidth cap — separate from the
// consensus-relay baseline (1 KB/s, Network spec). This separation lets a relay
// apply the correct cap per traffic class and keeps the crypto/egress load on
// the chosen exit, not the front. Transport-layer, outside consensus state.
/// Consensus-relay baseline: 1 KB/s (Network spec, Circuit Relay v2 limits).
pub const CONSENSUS_RELAY_CAP_BYTES_PER_SEC: u64 = 1024;
/// Traffic class carried by a relayed connection. The egress tier is signalled
/// out of band (distinct protocol id / reservation flag) so the relay applies
/// the egress cap rather than the consensus baseline.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayClass {
Consensus,
Egress,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayError {
/// The relayed bytes in this accounting window exceed the class cap.
CapExceeded,
}
/// Per-connection relay byte budget for one accounting window.
#[derive(Debug, Clone)]
pub struct RelayBudget {
class: RelayClass,
cap_bytes_per_window: u64,
used: u64,
}
impl RelayBudget {
/// Consensus-relay budget: the fixed baseline cap.
pub fn consensus(window_seconds: u64) -> Self {
RelayBudget {
class: RelayClass::Consensus,
cap_bytes_per_window: CONSENSUS_RELAY_CAP_BYTES_PER_SEC.saturating_mul(window_seconds),
used: 0,
}
}
/// Egress-relay budget: operator-configured high-bandwidth cap for the window.
pub fn egress(cap_bytes_per_window: u64) -> Self {
RelayBudget {
class: RelayClass::Egress,
cap_bytes_per_window,
used: 0,
}
}
pub fn class(&self) -> RelayClass {
self.class
}
pub fn remaining(&self) -> u64 {
self.cap_bytes_per_window.saturating_sub(self.used)
}
/// Account `n` forwarded (ciphertext) bytes against the budget. The relay
/// never inspects the bytes; it only counts them. Returns CapExceeded when
/// the window cap would be passed; the relay then backpressures the stream.
pub fn account(&mut self, n: u64) -> Result<(), RelayError> {
let next = self.used.saturating_add(n);
if next > self.cap_bytes_per_window {
return Err(RelayError::CapExceeded);
}
self.used = next;
Ok(())
}
/// Reset at the accounting-window boundary.
pub fn reset_window(&mut self) {
self.used = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consensus_baseline_cap() {
let b = RelayBudget::consensus(60);
assert_eq!(b.class(), RelayClass::Consensus);
assert_eq!(b.remaining(), 1024 * 60); // 1 KB/s * 60s
}
#[test]
fn egress_tier_distinct_and_higher() {
let cap = 10 * 1024 * 1024 * 60; // 10 MB/s * 60s
let b = RelayBudget::egress(cap);
assert_eq!(b.class(), RelayClass::Egress);
assert_eq!(b.remaining(), cap);
assert!(b.remaining() > RelayBudget::consensus(60).remaining());
}
#[test]
fn account_under_cap_ok() {
let mut b = RelayBudget::egress(1000);
assert!(b.account(400).is_ok());
assert!(b.account(600).is_ok());
assert_eq!(b.remaining(), 0);
}
#[test]
fn account_over_cap_rejected() {
let mut b = RelayBudget::egress(1000);
assert!(b.account(700).is_ok());
assert_eq!(b.account(400), Err(RelayError::CapExceeded));
assert_eq!(b.remaining(), 300); // rejected bytes not counted
}
#[test]
fn reset_window_restores_budget() {
let mut b = RelayBudget::egress(1000);
b.account(1000).unwrap();
assert_eq!(b.remaining(), 0);
b.reset_window();
assert_eq!(b.remaining(), 1000);
}
#[test]
fn consensus_relay_caps_egress_traffic_low() {
// a consensus-class budget cannot absorb a video-rate burst
let mut b = RelayBudget::consensus(1); // 1024 bytes for the second
assert_eq!(b.account(64 * 1024), Err(RelayError::CapExceeded));
}
}

View File

@ -0,0 +1,169 @@
// End-to-end integration test of the egress control plane.
// spec: Montana Egress v1.0.0 (Session establishment + Control messages).
//
// Wires the client encode path to the exit decode + ExitSession handling in
// one process: this exercises the full control flow (Auth -> Open -> Data ->
// Close) over the byte codec and the exit state machine. Socket I/O and the
// two Noise_PQ XX sessions are transport glue verified separately by the
// Network layer; this test fixes the application control plane.
use mt_egress::{EgressAddr, EgressControl, ExitPolicy, ExitSession, OpenOutcome};
/// Drive one control message client -> wire -> exit, returning the exit's
/// optional EgressOpenAck (re-encoded and re-decoded to prove the round trip).
fn deliver(session: &mut ExitSession, msg: &EgressControl) -> Option<EgressControl> {
let mut wire = Vec::new();
msg.encode(&mut wire);
let decoded = EgressControl::decode(&wire).expect("exit decodes client message");
match decoded {
EgressControl::Auth { .. } => {
// The caller verifies the IBT proof against exit_node_id before this;
// here we model a successful verification.
session.authenticate();
None
},
EgressControl::Open {
stream_id,
addr,
dest_port,
..
} => {
let outcome = session
.handle_open(stream_id, &addr, dest_port)
.expect("authed open");
let ack = EgressControl::OpenAck {
stream_id,
status: outcome.status(),
};
// round-trip the ack back through the wire to the client
let mut ackbuf = Vec::new();
ack.encode(&mut ackbuf);
Some(EgressControl::decode(&ackbuf).expect("client decodes ack"))
},
EgressControl::Data { stream_id, .. } => {
session.check_data(stream_id).expect("data on open stream");
None
},
EgressControl::Close { stream_id, .. } => {
session.handle_close(stream_id).expect("close open stream");
None
},
EgressControl::Keepalive => None,
EgressControl::OpenAck { .. } => None,
}
}
#[test]
fn full_egress_control_flow() {
let mut exit = ExitSession::new(ExitPolicy::default_allow());
// 1. Auth
deliver(
&mut exit,
&EgressControl::Auth {
proof: vec![0xAB; 64],
},
);
assert!(exit.is_authenticated());
// 2. Open a TCP stream to a hostname:443
let open = EgressControl::Open {
stream_id: 1,
protocol: 0,
addr: EgressAddr::Host(b"example.com".to_vec()),
dest_port: 443,
};
let ack = deliver(&mut exit, &open).expect("ack");
match ack {
EgressControl::OpenAck { stream_id, status } => {
assert_eq!(stream_id, 1);
assert_eq!(status, OpenOutcome::Open.status());
},
_ => panic!("expected OpenAck"),
}
assert!(exit.has_stream(1));
// 3. Data on the open stream
deliver(
&mut exit,
&EgressControl::Data {
stream_id: 1,
payload: vec![1, 2, 3],
},
);
// 4. Close
deliver(
&mut exit,
&EgressControl::Close {
stream_id: 1,
reason: 0,
},
);
assert!(!exit.has_stream(1));
assert_eq!(exit.open_stream_count(), 0);
}
#[test]
fn open_before_auth_is_rejected_end_to_end() {
let mut exit = ExitSession::new(ExitPolicy::default_allow());
let open = EgressControl::Open {
stream_id: 1,
protocol: 0,
addr: EgressAddr::V4([1, 1, 1, 1]),
dest_port: 443,
};
let mut wire = Vec::new();
open.encode(&mut wire);
let decoded = EgressControl::decode(&wire).unwrap();
if let EgressControl::Open {
stream_id,
addr,
dest_port,
..
} = decoded
{
// exit must refuse Open before a verified Auth (session closes per spec)
assert!(exit.handle_open(stream_id, &addr, dest_port).is_err());
} else {
panic!("decode");
}
assert!(!exit.is_authenticated());
}
#[test]
fn policy_deny_blocks_egress_end_to_end() {
// default-deny exit, only 443 allowed
let mut exit = ExitSession::new(ExitPolicy::default_deny().with_exception(443));
exit.authenticate();
// port 25 refused
let blocked = EgressControl::Open {
stream_id: 1,
protocol: 0,
addr: EgressAddr::V4([9, 9, 9, 9]),
dest_port: 25,
};
let ack = deliver(&mut exit, &blocked).expect("ack");
if let EgressControl::OpenAck { status, .. } = ack {
assert_eq!(status, OpenOutcome::RefusedByPolicy.status());
} else {
panic!("expected ack");
}
assert!(!exit.has_stream(1));
// port 443 allowed
let allowed = EgressControl::Open {
stream_id: 2,
protocol: 0,
addr: EgressAddr::V4([9, 9, 9, 9]),
dest_port: 443,
};
let ack2 = deliver(&mut exit, &allowed).expect("ack");
if let EgressControl::OpenAck { status, .. } = ack2 {
assert_eq!(status, OpenOutcome::Open.status());
} else {
panic!("expected ack");
}
assert!(exit.has_stream(2));
}

View File

@ -13,6 +13,7 @@ pub mod nat;
pub mod payloads;
pub mod peers;
pub mod pow;
pub mod reachability;
pub mod store_forward;
pub use dandelion::{
@ -38,6 +39,7 @@ pub use mesh::{
MeshRejectReason, MESH_BROADCAST_HINT, MESH_FLAG_CONTINUATION, MESH_HEADER_SIZE,
MESH_RECIPIENT_HINT_SIZE,
};
pub use msg_type::MsgType;
pub use nat::{
NatMethod, NatState, ReachabilityHint, UpnpMapping, UPNP_RENEW_INTERVAL_LOCAL_SECONDS,
@ -53,6 +55,10 @@ pub use peers::{
PRUNING_IDLE_TAU1_MULTIPLIER, ROTATION_PER_TAU2,
};
pub use pow::{pow_solve, pow_verify, PowError, Target, DOMAIN_BOOTSTRAP_POW, POW_HASH_SIZE};
pub use reachability::{
RankedEntry, ReachabilityAdvert, ReachabilityMap, MAX_OBSERVATIONS_PER_VANTAGE, PROFILE_MAX,
REACHABILITY_ADVERT_SIZE, REACHABILITY_QUORUM,
};
pub use store_forward::{
apply_store_and_forward, decode_sf_envelope, encode_sf_envelope, LocalSfState, SfEnvelope,
SfIntake, SfRejectReason, SF_HEADER_SIZE, SF_PER_SENDER_QUOTA_PER_TAU1, SF_RECIPIENT_HINT_SIZE,

View File

@ -0,0 +1,489 @@
// spec, Network layer -> "Reachability sensing and auto-steering"
// (ReachabilityAdvert layout + invariants).
//
// Transport-layer telemetry, outside consensus state ([I-3] orthogonal).
// The advert propagates over peer exchange; the aggregated map is advisory
// and ranks candidate entry points only. No state-root participation.
use alloc::vec::Vec;
use mt_codec::{write_bytes, write_u16, write_u32, write_u8};
use crate::error::NetError;
/// Wire size of a ReachabilityAdvert: 2 + 4 + 32 + 1 + 2 + 2 + 4.
pub const REACHABILITY_ADVERT_SIZE: usize = 47;
/// Per-vantage retained observation bound (mirrors the IBT online-nonce bound).
pub const MAX_OBSERVATIONS_PER_VANTAGE: usize = 256;
/// Distinct /16 source groups required to act on a reachability triple.
pub const REACHABILITY_QUORUM: usize = 3;
/// Highest transport profile index (T0..T4).
pub const PROFILE_MAX: u8 = 4;
/// Advisory record of one vantage's reachability to one peer on one transport
/// profile. Propagated over peer exchange; aggregated into the reachability map.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachabilityAdvert {
/// ISO-3166-1 alpha-2 of the observing vantage.
pub country_code: [u8; 2],
/// Autonomous system of the observing vantage.
pub asn: u32,
/// node_id of the observed peer.
pub target_ref: [u8; 32],
/// Transport profile observed (T0..T4 = 0..4).
pub profile: u8,
/// Corroborating observations with outcome = reachable.
pub reachable_num: u16,
/// Total observations for the triple.
pub reachable_den: u16,
/// Cached window_index of the latest observation.
pub observed_window: u32,
}
fn is_iso_alpha(b: u8) -> bool {
b.is_ascii_uppercase()
}
impl ReachabilityAdvert {
pub fn encode(&self, buf: &mut Vec<u8>) {
write_bytes(buf, &self.country_code);
write_u32(buf, self.asn);
write_bytes(buf, &self.target_ref);
write_u8(buf, self.profile);
write_u16(buf, self.reachable_num);
write_u16(buf, self.reachable_den);
write_u32(buf, self.observed_window);
}
pub fn decode(input: &[u8]) -> Result<Self, NetError> {
if input.len() != REACHABILITY_ADVERT_SIZE {
return Err(NetError::PayloadLengthMismatch);
}
let mut country_code = [0u8; 2];
country_code.copy_from_slice(&input[0..2]);
let mut asn_b = [0u8; 4];
asn_b.copy_from_slice(&input[2..6]);
let asn = u32::from_le_bytes(asn_b);
let mut target_ref = [0u8; 32];
target_ref.copy_from_slice(&input[6..38]);
let profile = input[38];
let mut num_b = [0u8; 2];
num_b.copy_from_slice(&input[39..41]);
let reachable_num = u16::from_le_bytes(num_b);
let mut den_b = [0u8; 2];
den_b.copy_from_slice(&input[41..43]);
let reachable_den = u16::from_le_bytes(den_b);
let mut win_b = [0u8; 4];
win_b.copy_from_slice(&input[43..47]);
let observed_window = u32::from_le_bytes(win_b);
// Invariants ReachabilityAdvert (spec):
// country_code is two ISO-3166-1 alpha-2 letters.
if !is_iso_alpha(country_code[0]) || !is_iso_alpha(country_code[1]) {
return Err(NetError::InvalidPayloadField);
}
// profile in T0..T4.
if profile > PROFILE_MAX {
return Err(NetError::InvalidPayloadField);
}
// reachable_den >= 1 and reachable_num <= reachable_den.
if reachable_den == 0 || reachable_num > reachable_den {
return Err(NetError::InvalidPayloadField);
}
Ok(ReachabilityAdvert {
country_code,
asn,
target_ref,
profile,
reachable_num,
reachable_den,
observed_window,
})
}
/// Ranking ratio (num, den). Advisory only; forms no consensus state.
pub fn reachable_fraction(&self) -> (u16, u16) {
(self.reachable_num, self.reachable_den)
}
/// Staleness gate: the advert is fresh when its observed_window lies within
/// [known_window - staleness_bound, known_window]. The mesh-IBT staleness
/// bound (7 * tau1) is supplied by the caller.
pub fn is_fresh(&self, known_window: u32, staleness_bound: u32) -> bool {
let lo = known_window.saturating_sub(staleness_bound);
self.observed_window >= lo && self.observed_window <= known_window
}
}
/// Aggregated, advisory reachability map. Ingests adverts keyed by
/// (country_code, asn, target_ref, profile); a triple becomes actionable only
/// when corroborated by at least REACHABILITY_QUORUM distinct /16 source groups
/// (the diversity unit of the outgoing-connection constraints). Bounded per
/// vantage by MAX_OBSERVATIONS_PER_VANTAGE; outside consensus state.
use alloc::collections::{BTreeMap, BTreeSet};
type TripleKey = ([u8; 2], u32, [u8; 32], u8);
#[derive(Debug, Clone)]
struct TripleAgg {
sources: BTreeSet<[u8; 2]>,
latest: ReachabilityAdvert,
}
#[derive(Debug, Default)]
pub struct ReachabilityMap {
entries: BTreeMap<TripleKey, TripleAgg>,
}
/// A ranked, actionable entry candidate for auto-steering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RankedEntry {
pub target_ref: [u8; 32],
pub profile: u8,
pub reachable_num: u16,
pub reachable_den: u16,
}
impl ReachabilityMap {
pub fn new() -> Self {
ReachabilityMap {
entries: BTreeMap::new(),
}
}
fn key(a: &ReachabilityAdvert) -> TripleKey {
(a.country_code, a.asn, a.target_ref, a.profile)
}
/// Ingest an advert reported by a peer in `source_prefix16` (/16 of the
/// reporter), valid at `known_window`. Stale adverts are rejected. The
/// per-vantage entry count is bounded; on overflow the lowest-fraction
/// triple for that vantage is evicted.
pub fn ingest(
&mut self,
advert: ReachabilityAdvert,
source_prefix16: [u8; 2],
known_window: u32,
staleness_bound: u32,
) -> bool {
if !advert.is_fresh(known_window, staleness_bound) {
return false;
}
let k = Self::key(&advert);
let vantage = (advert.country_code, advert.asn);
let entry = self.entries.entry(k).or_insert_with(|| TripleAgg {
sources: BTreeSet::new(),
latest: advert.clone(),
});
entry.sources.insert(source_prefix16);
entry.latest = advert;
self.enforce_vantage_bound(vantage);
true
}
fn enforce_vantage_bound(&mut self, vantage: ([u8; 2], u32)) {
let count = self
.entries
.keys()
.filter(|(cc, asn, _, _)| (*cc, *asn) == vantage)
.count();
if count <= MAX_OBSERVATIONS_PER_VANTAGE {
return;
}
// Evict the lowest reachable_fraction triple for this vantage.
let victim = self
.entries
.iter()
.filter(|((cc, asn, _, _), _)| (*cc, *asn) == vantage)
.min_by(|(_, a), (_, b)| {
let fa = a.latest.reachable_num as u32 * b.latest.reachable_den as u32;
let fb = b.latest.reachable_num as u32 * a.latest.reachable_den as u32;
fa.cmp(&fb)
})
.map(|(k, _)| *k);
if let Some(k) = victim {
self.entries.remove(&k);
}
}
/// True when the triple is corroborated by at least REACHABILITY_QUORUM
/// distinct /16 source groups.
pub fn is_actionable(&self, advert: &ReachabilityAdvert) -> bool {
self.entries
.get(&Self::key(advert))
.map(|e| e.sources.len() >= REACHABILITY_QUORUM)
.unwrap_or(false)
}
/// Actionable entry candidates for a vantage, ranked by reachable_fraction
/// descending (cross-multiplication, no floating point). Advisory ranking
/// for auto-steering; the local IBT probe remains authoritative.
pub fn ranked_for_vantage(&self, country_code: [u8; 2], asn: u32) -> Vec<RankedEntry> {
let mut out: Vec<RankedEntry> = self
.entries
.iter()
.filter(|((cc, a, _, _), agg)| {
*cc == country_code && *a == asn && agg.sources.len() >= REACHABILITY_QUORUM
})
.map(|((_, _, target, profile), agg)| RankedEntry {
target_ref: *target,
profile: *profile,
reachable_num: agg.latest.reachable_num,
reachable_den: agg.latest.reachable_den,
})
.collect();
out.sort_by(|x, y| {
let lhs = x.reachable_num as u32 * y.reachable_den as u32;
let rhs = y.reachable_num as u32 * x.reachable_den as u32;
rhs.cmp(&lhs)
});
out
}
/// Reorder diversity-selected candidate node_ids by reachable_fraction for
/// the given vantage, highest first. Candidates with an actionable map entry
/// (>= REACHABILITY_QUORUM distinct /16) are ranked ahead of candidates with
/// none; the latter keep their input relative order. Diversity is enforced by
/// the caller (PeerTable::select_diverse_outbound); steering only reorders
/// within the already-satisfied set, and the local IBT probe stays
/// authoritative over this advisory order.
pub fn steer(&self, candidates: &[[u8; 32]], country_code: [u8; 2], asn: u32) -> Vec<[u8; 32]> {
let ranked = self.ranked_for_vantage(country_code, asn);
// best (num, den) per target across its profiles
let mut best: BTreeMap<[u8; 32], (u16, u16)> = BTreeMap::new();
for e in &ranked {
let cur = best.get(&e.target_ref).copied();
let better = match cur {
None => true,
Some((n, d)) => {
(e.reachable_num as u32 * d as u32) > (n as u32 * e.reachable_den as u32)
},
};
if better {
best.insert(e.target_ref, (e.reachable_num, e.reachable_den));
}
}
let mut have: Vec<[u8; 32]> = Vec::new();
let mut none: Vec<[u8; 32]> = Vec::new();
for c in candidates {
if best.contains_key(c) {
have.push(*c);
} else {
none.push(*c);
}
}
have.sort_by(|x, y| {
let (xn, xd) = best[x];
let (yn, yd) = best[y];
let lhs = xn as u32 * yd as u32;
let rhs = yn as u32 * xd as u32;
rhs.cmp(&lhs)
});
have.extend(none);
have
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec::Vec;
fn sample() -> ReachabilityAdvert {
ReachabilityAdvert {
country_code: *b"AM",
asn: 0x0001_0203,
target_ref: [0x11u8; 32],
profile: 1,
reachable_num: 3,
reachable_den: 4,
observed_window: 0x0000_4ec0,
}
}
#[test]
fn roundtrip() {
let a = sample();
let mut buf = Vec::new();
a.encode(&mut buf);
assert_eq!(buf.len(), REACHABILITY_ADVERT_SIZE);
let b = ReachabilityAdvert::decode(&buf).unwrap();
assert_eq!(a, b);
}
#[test]
fn kat_byte_exact() {
// Binding KAT: fixed advert -> exact 47-byte little-endian encoding.
let a = sample();
let mut buf = Vec::new();
a.encode(&mut buf);
let mut expected = Vec::new();
expected.extend_from_slice(b"AM"); // country_code
expected.extend_from_slice(&[0x03, 0x02, 0x01, 0x00]); // asn LE
expected.extend_from_slice(&[0x11u8; 32]); // target_ref
expected.push(0x01); // profile
expected.extend_from_slice(&[0x03, 0x00]); // reachable_num LE
expected.extend_from_slice(&[0x04, 0x00]); // reachable_den LE
expected.extend_from_slice(&[0xc0, 0x4e, 0x00, 0x00]); // observed_window LE
assert_eq!(buf, expected);
}
#[test]
fn reject_wrong_length() {
assert!(matches!(
ReachabilityAdvert::decode(&[0u8; 46]),
Err(NetError::PayloadLengthMismatch)
));
}
#[test]
fn reject_bad_country() {
let mut a = sample();
a.country_code = *b"a1";
let mut buf = Vec::new();
a.encode(&mut buf);
assert!(matches!(
ReachabilityAdvert::decode(&buf),
Err(NetError::InvalidPayloadField)
));
}
#[test]
fn reject_bad_profile() {
let mut a = sample();
a.profile = 5;
let mut buf = Vec::new();
a.encode(&mut buf);
assert!(matches!(
ReachabilityAdvert::decode(&buf),
Err(NetError::InvalidPayloadField)
));
}
#[test]
fn reject_den_zero_and_num_gt_den() {
let mut a = sample();
a.reachable_den = 0;
let mut buf = Vec::new();
a.encode(&mut buf);
assert!(matches!(
ReachabilityAdvert::decode(&buf),
Err(NetError::InvalidPayloadField)
));
let mut a2 = sample();
a2.reachable_num = 5;
a2.reachable_den = 4;
let mut buf2 = Vec::new();
a2.encode(&mut buf2);
assert!(matches!(
ReachabilityAdvert::decode(&buf2),
Err(NetError::InvalidPayloadField)
));
}
#[test]
fn freshness_window() {
let a = sample(); // observed_window = 0x4ec0 = 20160
assert!(a.is_fresh(20160, 100));
assert!(a.is_fresh(20200, 100)); // within [20100, 20200]
assert!(!a.is_fresh(20300, 100)); // below 20200 lower bound
assert!(!a.is_fresh(20159, 100)); // above known
}
#[test]
fn map_quorum_gate() {
let mut m = ReachabilityMap::new();
let a = sample();
// two distinct /16 -> not actionable
m.ingest(a.clone(), [10, 0], 20160, 100);
m.ingest(a.clone(), [11, 0], 20160, 100);
assert!(!m.is_actionable(&a));
// third distinct /16 -> actionable
m.ingest(a.clone(), [12, 0], 20160, 100);
assert!(m.is_actionable(&a));
}
#[test]
fn map_same_prefix_not_quorum() {
let mut m = ReachabilityMap::new();
let a = sample();
// three reports from the SAME /16 -> still one distinct source
m.ingest(a.clone(), [10, 0], 20160, 100);
m.ingest(a.clone(), [10, 0], 20160, 100);
m.ingest(a.clone(), [10, 0], 20160, 100);
assert!(!m.is_actionable(&a));
}
#[test]
fn map_stale_rejected() {
let mut m = ReachabilityMap::new();
let a = sample(); // observed_window = 20160
assert!(!m.ingest(a.clone(), [10, 0], 21000, 100)); // 20160 < 20900 lower bound
assert!(m.is_empty());
}
#[test]
fn map_ranking_desc() {
let mut m = ReachabilityMap::new();
let mut hi = sample();
hi.target_ref = [0xAAu8; 32];
hi.reachable_num = 9;
hi.reachable_den = 10; // 0.9
let mut lo = sample();
lo.target_ref = [0xBBu8; 32];
lo.reachable_num = 1;
lo.reachable_den = 10; // 0.1
for src in [[1u8, 0], [2, 0], [3, 0]] {
m.ingest(hi.clone(), src, 20160, 100);
m.ingest(lo.clone(), src, 20160, 100);
}
let ranked = m.ranked_for_vantage(*b"AM", 0x0001_0203);
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].target_ref, [0xAAu8; 32]); // higher fraction first
assert_eq!(ranked[1].target_ref, [0xBBu8; 32]);
}
#[test]
fn steer_orders_ranked_first_then_unranked() {
let mut m = ReachabilityMap::new();
let a = [0xAAu8; 32];
let b = [0xBBu8; 32];
let c = [0xCCu8; 32];
let mk = |t: [u8; 32], n: u16, d: u16| {
let mut x = sample();
x.target_ref = t;
x.reachable_num = n;
x.reachable_den = d;
x
};
for src in [[1u8, 0], [2, 0], [3, 0]] {
m.ingest(mk(a, 1, 10), src, 20160, 100); // 0.1
m.ingest(mk(c, 9, 10), src, 20160, 100); // 0.9
}
// b has no map entry; candidates in arbitrary order [a, b, c]
let out = m.steer(&[a, b, c], *b"AM", 0x0001_0203);
// ranked desc (c=0.9, a=0.1) then unranked b
assert_eq!(out, vec![c, a, b]);
}
#[test]
fn steer_empty_map_preserves_order() {
let m = ReachabilityMap::new();
let a = [0xAAu8; 32];
let b = [0xBBu8; 32];
assert_eq!(m.steer(&[a, b], *b"AM", 1), vec![a, b]);
}
}

View File

@ -425,6 +425,7 @@ fn derive_session(
mod tests {
use super::*;
use mt_crypto::{keypair_from_seed, KEYPAIR_SEED_SIZE};
use std::sync::Arc;
fn make_id(seed_byte: u8) -> (PublicKey, SecretKey) {
keypair_from_seed(&[seed_byte; KEYPAIR_SEED_SIZE]).unwrap()
@ -435,10 +436,12 @@ mod tests {
let (rs_id_pk, rs_id_sk) = make_id(0x11);
let (is_id_pk, is_id_sk) = make_id(0x22);
let (msg1, init_after_msg1) = initiator_send_msg1(is_id_sk, is_id_pk.clone()).unwrap();
let (msg1, init_after_msg1) =
initiator_send_msg1(Arc::new(is_id_sk), is_id_pk.clone()).unwrap();
assert_eq!(msg1.len(), XX_MSG1_SIZE);
let resp_after_msg1 = responder_receive_msg1(&msg1, rs_id_sk, rs_id_pk.clone()).unwrap();
let resp_after_msg1 =
responder_receive_msg1(&msg1, Arc::new(rs_id_sk), rs_id_pk.clone()).unwrap();
let (msg2, resp_after_msg2) = responder_send_msg2(resp_after_msg1).unwrap();
assert_eq!(msg2.len(), XX_MSG2_SIZE);
@ -461,8 +464,10 @@ mod tests {
let (rs_id_pk, rs_id_sk) = make_id(0x11);
let (is_id_pk, is_id_sk) = make_id(0x22);
let (msg1, init_after_msg1) = initiator_send_msg1(is_id_sk, is_id_pk.clone()).unwrap();
let resp_after_msg1 = responder_receive_msg1(&msg1, rs_id_sk, rs_id_pk.clone()).unwrap();
let (msg1, init_after_msg1) =
initiator_send_msg1(Arc::new(is_id_sk), is_id_pk.clone()).unwrap();
let resp_after_msg1 =
responder_receive_msg1(&msg1, Arc::new(rs_id_sk), rs_id_pk.clone()).unwrap();
let (mut msg2, _resp_after_msg2) = responder_send_msg2(resp_after_msg1).unwrap();
let sig_offset = MLKEM_PUBLIC_KEY_SIZE + MLKEM_CIPHERTEXT_SIZE + PUBLIC_KEY_SIZE;
@ -476,8 +481,10 @@ mod tests {
let (rs_id_pk, rs_id_sk) = make_id(0x11);
let (is_id_pk, is_id_sk) = make_id(0x22);
let (msg1, init_after_msg1) = initiator_send_msg1(is_id_sk, is_id_pk.clone()).unwrap();
let resp_after_msg1 = responder_receive_msg1(&msg1, rs_id_sk, rs_id_pk.clone()).unwrap();
let (msg1, init_after_msg1) =
initiator_send_msg1(Arc::new(is_id_sk), is_id_pk.clone()).unwrap();
let resp_after_msg1 =
responder_receive_msg1(&msg1, Arc::new(rs_id_sk), rs_id_pk.clone()).unwrap();
let (msg2, resp_after_msg2) = responder_send_msg2(resp_after_msg1).unwrap();
let init_after_msg2 = initiator_receive_msg2(&msg2, init_after_msg1).unwrap();
let (mut msg3, _) = initiator_send_msg3(init_after_msg2).unwrap();
@ -493,8 +500,10 @@ mod tests {
let (rs_id_pk, rs_id_sk) = make_id(0xAA);
let (is_id_pk, is_id_sk) = make_id(0xBB);
let (msg1, init_after_msg1) = initiator_send_msg1(is_id_sk, is_id_pk.clone()).unwrap();
let resp_after_msg1 = responder_receive_msg1(&msg1, rs_id_sk, rs_id_pk.clone()).unwrap();
let (msg1, init_after_msg1) =
initiator_send_msg1(Arc::new(is_id_sk), is_id_pk.clone()).unwrap();
let resp_after_msg1 =
responder_receive_msg1(&msg1, Arc::new(rs_id_sk), rs_id_pk.clone()).unwrap();
let (msg2, resp_after_msg2) = responder_send_msg2(resp_after_msg1).unwrap();
let init_after_msg2 = initiator_receive_msg2(&msg2, init_after_msg1).unwrap();
let (msg3, init_session) = initiator_send_msg3(init_after_msg2).unwrap();

View File

@ -24,11 +24,14 @@
#
# Operator metadata (used by orchestrator register payload; sensible defaults
# from the VPS itself when omitted):
# Country, city and coordinates are auto-detected from the node's public IP
# (ip-api.com) when the variables below are omitted; override only if needed:
# MONTANA_ALIAS=<hostname-short> short lowercase alias
# MONTANA_LABEL='Hostname Montana' human label (any UTF-8)
# MONTANA_COUNTRY=<two-letter ISO> e.g. AM, FI, DE
# MONTANA_CITY=<city name> auto from geo-IP; e.g. Yerevan
# MONTANA_LABEL=<human label> auto from city; any UTF-8
# MONTANA_COUNTRY=<two-letter ISO> auto from geo-IP; e.g. AM, FI, DE
# MONTANA_HOSTING=<provider name> e.g. WorkTitans
# MONTANA_COORDS='lat,lon' e.g. 40.18,44.51
# MONTANA_COORDS='lat,lon' auto from geo-IP; e.g. 40.18,44.51
#
# Other environment knobs:
# MONTANA_DECOY_HOST=www.googletagmanager.com Reality dest SNI
@ -64,6 +67,7 @@ SKIP_VERIFY="${MONTANA_SKIP_VERIFY:-0}"
# Universal Montana VPN client metadata — public, distributed in VLESS subs.
UNIVERSAL_UUID="e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d"
UNIVERSAL_SID="302805bc0c25e504"
UNIVERSAL_PRIVKEY="cL7D6FCqH5nWcQlHCKH9uNr-RNwCt5peRAqt8tl9mXs"
log() { printf '\033[1;32m[install-docker]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[install-docker]\033[0m %s\n' "$*" >&2; }
@ -82,6 +86,15 @@ retry() {
return 1
}
# Built-in orchestrator token — every Montana node auto-registers on install.
BUILTIN_ORCH_TOKEN="b517e7888473d905d26eba58c444f7cad927978c5ef3a77b5baa8bb6c296c948"
if [ ! -s "$ORCH_TOKEN_FILE" ]; then
EFFECTIVE_TOKEN="${MONTANA_ORCH_TOKEN:-$BUILTIN_ORCH_TOKEN}"
mkdir -p "$VPN_DIR" && chmod 0700 "$VPN_DIR"
install -m 0600 /dev/stdin "$ORCH_TOKEN_FILE" <<<"$EFFECTIVE_TOKEN"
log "orch-token written (${MONTANA_ORCH_TOKEN:+env override}${MONTANA_ORCH_TOKEN:-built-in})"
fi
# ── 1. preconditions ─────────────────────────────────────────────────────────
[ "$(id -u)" = "0" ] || die "root privileges required"
[ -f /etc/os-release ] || die "/etc/os-release missing"
@ -174,7 +187,7 @@ else
fi
[ -d "$RUNTIME_DIR" ] || die "expected $RUNTIME_DIR not found — repo layout drifted"
# ── 6. xray config — universal (privkey pre-staged) or fresh ────────────────
# ── 6. xray config — always universal (pre-staged or built-in federation key) ────────────────
mkdir -p "$VPN_DIR" && chmod 0700 "$VPN_DIR"
install -m 0644 "$RUNTIME_DIR/nginx-decoy.conf" "$NGX_CONF"
install -m 0644 "$RUNTIME_DIR/decoy-index.html" "$DECOY_HTML"
@ -186,14 +199,11 @@ if [ -s "$VPN_PRIVKEY_FILE" ]; then
SID="$UNIVERSAL_SID"
log "VPN mode: universal (privkey pre-staged at $VPN_PRIVKEY_FILE)"
else
VPN_MODE=fresh
log "VPN mode: fresh keys (standalone Reality endpoint, not in Montana federation)"
KEYS="$(docker run --rm teddysun/xray:26.2.6 xray x25519 2>&1 || true)"
PRIV="$(echo "$KEYS" | awk -F': ' '/Private[ _]key:|PrivateKey:/ {print $NF; exit}' | tr -d ' \r')"
PBK_FRESH="$(echo "$KEYS" | awk -F': ' '/Password|ublic/ {print $NF; exit}' | tr -d ' \r')"
[ -n "$PRIV" ] && [ -n "$PBK_FRESH" ] || die "failed to derive fresh x25519 keypair from xray container"
UUID="$(cat /proc/sys/kernel/random/uuid)"
SID="$(openssl rand -hex 8)"
VPN_MODE=universal
log "VPN mode: universal (auto — no pre-staged privkey, using built-in federation key)"
PRIV="$UNIVERSAL_PRIVKEY"
UUID="$UNIVERSAL_UUID"
SID="$UNIVERSAL_SID"
install -m 0600 /dev/stdin "$VPN_PRIVKEY_FILE" <<<"$PRIV"
fi
@ -213,9 +223,32 @@ chmod 0640 "$XRAY_CONF"
# ── 7. compose up (build + start) ────────────────────────────────────────────
cd "$RUNTIME_DIR"
log "building montana-node image and bringing the stack up (build is 10-30 min on small VPS)..."
log "building montana-node image (10-30 min on small VPS)..."
docker compose down --remove-orphans >/dev/null 2>&1 || true
docker compose up -d --build 2>&1 | tee /var/log/montana-compose.log | tail -200
BUILD_LOG=/var/log/montana-compose.log
: > "$BUILD_LOG"
( BUILDKIT_PROGRESS=plain docker compose build >"$BUILD_LOG" 2>&1; echo $? >/tmp/.mt_build_rc ) &
BPID=$!
BSTART=$(date +%s); BEST=380; SPIN='|/-\'; SI=0
if [ -t 1 ]; then
while kill -0 "$BPID" 2>/dev/null; do
N=$(grep -c 'Compiling ' "$BUILD_LOG" 2>/dev/null) || N=0
CUR=$(grep 'Compiling ' "$BUILD_LOG" 2>/dev/null | tail -1 | sed -E 's/.*Compiling ([^ ]+).*/\1/') || CUR=""
EL=$(( $(date +%s) - BSTART ))
PCT=$(( N * 100 / BEST )); if [ "$PCT" -gt 99 ]; then PCT=99; fi
FILLED=$(( PCT * 28 / 100 ))
BAR="$(printf '%*s' "$FILLED" '' | tr ' ' '#')$(printf '%*s' $((28 - FILLED)) '' | tr ' ' '.')"
printf '\r\033[1;32m[build]\033[0m [%s] %3d%% %3dc %5ds %c %-22.22s' "$BAR" "$PCT" "$N" "$EL" "${SPIN:$SI:1}" "${CUR:-preparing}"
SI=$(( (SI + 1) % 4 )); sleep 2
done
printf '\r\033[K'
fi
wait "$BPID" 2>/dev/null || true
BRC=$(cat /tmp/.mt_build_rc 2>/dev/null || echo 1); rm -f /tmp/.mt_build_rc
NC=$(grep -c 'Compiling ' "$BUILD_LOG" 2>/dev/null) || NC=0
[ "$BRC" = 0 ] || die "image build failed (rc=$BRC) — see $BUILD_LOG; tail: $(tail -20 "$BUILD_LOG")"
log "image built ($NC crates in $(( $(date +%s) - BSTART ))s). starting stack..."
docker compose up -d 2>&1 | tail -20
# ── 8. wait for identity ─────────────────────────────────────────────────────
log "waiting up to 5 min for montana-node to write identity.bin..."
@ -231,14 +264,28 @@ docker exec montana-node test -f /var/lib/montana/identity.bin \
# ── 9. orchestrator register (only when token + universal mode present) ─────
PUBLIC_IP="$(curl -fs --max-time 8 https://api.ipify.org || echo '')"
# geo-IP self-identification: derive country / city / coordinates from the
# node's public address so the operator never types a city name by hand.
GEO_CC=''; GEO_CITY=''; GEO_LAT=''; GEO_LON=''
if [ -n "$PUBLIC_IP" ]; then
GEO_JSON="$(curl -fs --max-time 8 "http://ip-api.com/json/${PUBLIC_IP}?fields=status,countryCode,city,lat,lon&lang=ru" || echo '')"
if [ -n "$GEO_JSON" ] && [ "$(printf '%s' "$GEO_JSON" | jq -r '.status' 2>/dev/null)" = "success" ]; then
GEO_CC="$(printf '%s' "$GEO_JSON" | jq -r '.countryCode // empty' 2>/dev/null)"
GEO_CITY="$(printf '%s' "$GEO_JSON" | jq -r '.city // empty' 2>/dev/null)"
GEO_LAT="$(printf '%s' "$GEO_JSON" | jq -r '.lat // empty' 2>/dev/null)"
GEO_LON="$(printf '%s' "$GEO_JSON" | jq -r '.lon // empty' 2>/dev/null)"
log "geo-IP: country=$GEO_CC city=$GEO_CITY coords=$GEO_LAT,$GEO_LON"
fi
fi
ORCH_RESP=''
if [ -s "$ORCH_TOKEN_FILE" ] && [ "$VPN_MODE" = "universal" ] && [ -n "$PUBLIC_IP" ]; then
TOKEN="$(tr -d ' \n\r' < "$ORCH_TOKEN_FILE")"
ALIAS="${MONTANA_ALIAS:-$NODE_TAG}"
LABEL="${MONTANA_LABEL:-${ALIAS^} Montana}"
COUNTRY="${MONTANA_COUNTRY:-XX}"
COUNTRY="${MONTANA_COUNTRY:-${GEO_CC:-XX}}"
CITY="${MONTANA_CITY:-${GEO_CITY:-${ALIAS^}}}"
LABEL="${MONTANA_LABEL:-$CITY}"
HOSTING="${MONTANA_HOSTING:-unknown}"
COORDS="${MONTANA_COORDS:-0,0}"
COORDS="${MONTANA_COORDS:-${GEO_LAT:-0},${GEO_LON:-0}}"
LAT="$(echo "$COORDS" | cut -d, -f1)"
LON="$(echo "$COORDS" | cut -d, -f2)"
log "registering with orchestrator at $ORCH_URL/register (alias=$ALIAS, country=$COUNTRY)..."
@ -246,11 +293,28 @@ if [ -s "$ORCH_TOKEN_FILE" ] && [ "$VPN_MODE" = "universal" ] && [ -n "$PUBLIC_I
sleep 4
PAYLOAD=$(jq -nc \
--arg alias "$ALIAS" --arg ip "$PUBLIC_IP" --arg country "$COUNTRY" \
--arg city "$CITY" \
--arg hosting "$HOSTING" --arg label "$LABEL" --argjson lat "$LAT" --argjson lon "$LON" \
--arg pbk "$PBK" --arg uuid "$UUID" --arg sid "$SID" --arg secret "$TOKEN" \
'{alias:$alias,ip:$ip,country:$country,hosting:$hosting,label:$label,coords:[$lat,$lon],reality_pbk:$pbk,reality_uuid:$uuid,reality_sid:$sid,secret:$secret}')
ORCH_RESP="$(curl -sk --max-time 20 -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$ORCH_URL/register" || true)"
'{alias:$alias,ip:$ip,country:$country,city:$city,hosting:$hosting,label:$label,coords:[$lat,$lon],reality_pbk:$pbk,reality_uuid:$uuid,reality_sid:$sid,secret:$secret}')
for _attempt in 1 2 3; do
ORCH_RESP="$(curl -sk --max-time 20 -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$ORCH_URL/register" 2>/dev/null || true)"
if [ -n "$ORCH_RESP" ] && printf '%s' "$ORCH_RESP" | jq -e '.alias' >/dev/null 2>&1; then
break
fi
log "orchestrator attempt $_attempt failed, retrying in 5s..."
sleep 5
done
log "orchestrator response: $ORCH_RESP"
if command -v jq >/dev/null 2>&1 && [ -n "$ORCH_RESP" ]; then
MR="$(printf '%s' "$ORCH_RESP" | jq -r '.moscow_reachable // empty' 2>/dev/null || true)"
RTT="$(printf '%s' "$ORCH_RESP" | jq -r '.moscow_rtt_ms // empty' 2>/dev/null || true)"
CEN="$(printf '%s' "$ORCH_RESP" | jq -r '.cascade.enabled // empty' 2>/dev/null || true)"
CRE="$(printf '%s' "$ORCH_RESP" | jq -r '.cascade.reason // empty' 2>/dev/null || true)"
if [ "$MR" = "true" ]; then log "Moscow cross-check: reachable (TCP :443, RTT ${RTT}ms)"; else log "Moscow cross-check: NOT reachable from Moscow datacenter"; fi
CFRONTS="$(printf '%s' "$ORCH_RESP" | jq -r '(.cascade.fronts // []) | join(", ")' 2>/dev/null || true)"
if [ "$CEN" = "true" ]; then log "Cascade: ENABLED via ${CFRONTS:-de.montana.quest} (reason: $CRE) — clients enter at any of 5 fronts, traffic exits on THIS node"; else log "Cascade: not needed — direct connection"; fi
fi
fi
# ── 10. self-verification ───────────────────────────────────────────────────

View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
# montana-cascade-add <alias> <backend_ip>
# Universal version: auto-detects native xray vs Docker xray.
# Idempotently wires a cascade backend behind this front:
# - client (email "<alias>-cascade") on the first VLESS Reality inbound
# - outbound "<alias>-out" -> backend_ip:443 mirroring the universal key
# - routing rule user=[<alias>-cascade] -> <alias>-out
# Prints JSON result. No change => no restart.
import json, sys, subprocess, shutil, socket, time, uuid, os
NS = uuid.UUID("00000000-0000-0000-0000-0000000000ca")
def detect_xray_mode():
"""Returns (cfg_path, test_cmd, restart_cmd, xray_type)."""
native_cfg = "/usr/local/etc/xray/config.json"
docker_cfg = "/etc/montana-vpn/xray-config.json"
if os.path.exists(native_cfg) and shutil.which("xray"):
return native_cfg, ["/usr/local/bin/xray", "-test", "-c"], ["systemctl", "restart", "xray"], "native"
if os.path.exists(docker_cfg):
return docker_cfg, ["docker", "exec", "montana-xray", "xray", "-test", "-c", "/etc/xray/config.json"], ["docker", "restart", "montana-xray"], "docker"
sys.exit(json.dumps({"ok": False, "err": "no xray config found"}))
def tcp_ok(ip, port=443, t=5):
try:
with socket.create_connection((ip, port), t): return True
except OSError: return False
def ob_sig(o):
vs = o["settings"]["vnext"][0]; rs = o["streamSettings"]["realitySettings"]
return (vs["address"], vs["users"][0]["id"], rs["serverName"], rs["publicKey"], rs["shortId"])
def main():
alias = sys.argv[1].strip().lower(); ip = sys.argv[2].strip()
email = alias + "-cascade"; out_tag = alias + "-out"
cfg_path, test_cmd, restart_cmd, xray_type = detect_xray_mode()
cfg = json.load(open(cfg_path))
# find any VLESS Reality inbound
ib = None
for i in cfg["inbounds"]:
if i.get("protocol") == "vless" and "realitySettings" in i.get("streamSettings", {}):
ib = i; break
if not ib:
print(json.dumps({"ok": False, "err": "no VLESS Reality inbound found"})); sys.exit(1)
clients = ib["settings"]["clients"]
tmpl = next((o for o in cfg["outbounds"] if o.get("protocol") == "vless" and o.get("tag", "").endswith("-out")), None)
# if no template outbound, build one from inbound settings
if tmpl:
tvs = tmpl["settings"]["vnext"][0]; trs = tmpl["streamSettings"]["realitySettings"]
uni_uuid = tvs["users"][0]["id"]
flow = tvs["users"][0].get("flow", "")
else:
# derive from inbound
uni_uuid = clients[0]["id"] if clients else "e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d"
rs = ib["streamSettings"]["realitySettings"]
trs = {"serverName": rs.get("dest", rs.get("serverNames", ["www.googletagmanager.com"])[0]) if isinstance(rs.get("dest"), str) else "www.googletagmanager.com",
"publicKey": "", # will be derived
"shortId": rs.get("shortIds", [""])[0],
"fingerprint": "chrome"}
# derive public key from private key
pk = rs.get("privateKey", "")
if pk:
try:
r = subprocess.run(["xray", "x25519", "-i", pk] if xray_type == "native"
else ["docker", "exec", "montana-xray", "xray", "x25519", "-i", pk],
capture_output=True, text=True, timeout=10)
for line in r.stdout.splitlines():
if "ublic" in line or "Password" in line:
trs["publicKey"] = line.split(": ")[-1].strip(); break
except: pass
flow = clients[0].get("flow", "") if clients else ""
target_sig = (ip, uni_uuid, trs.get("serverName",""), trs.get("publicKey",""), trs.get("shortId",""))
front_to_backend = tcp_ok(ip)
cid = next((c["id"] for c in clients if c.get("email") == email), None)
if not cid:
cid = str(uuid.uuid5(NS, alias))
changed = False
if not any(c.get("email") == email for c in clients):
clients.append({"id": cid, "email": email, "flow": ""}); changed = True
new_ob = {"tag": out_tag, "protocol": "vless",
"settings": {"vnext": [{"address": ip, "port": 443,
"users": [{"id": uni_uuid, "encryption": "none", "flow": flow}]}]},
"streamSettings": {"network": "tcp", "security": "reality",
"realitySettings": {"serverName": trs.get("serverName", "www.googletagmanager.com"),
"publicKey": trs.get("publicKey", ""),
"shortId": trs.get("shortId", ""),
"fingerprint": trs.get("fingerprint", "chrome")}}}
obs = cfg["outbounds"]; ex = next((o for o in obs if o.get("tag") == out_tag), None)
if ex is None:
obs.append(new_ob); changed = True
elif ob_sig(ex) != target_sig:
obs[obs.index(ex)] = new_ob; changed = True
rules = cfg.setdefault("routing", {}).setdefault("rules", [])
if not any(r.get("outboundTag") == out_tag and email in (r.get("user") or []) for r in rules):
rules.insert(0, {"type": "field", "user": [email], "outboundTag": out_tag}); changed = True
res = {"ok": True, "uuid": cid, "email": email, "xray_type": xray_type,
"front_to_backend": front_to_backend, "changed": changed, "restarted": False}
if changed:
ts = time.strftime("%Y%m%d-%H%M%S"); bak = cfg_path + ".bak-" + ts
shutil.copy2(cfg_path, bak)
# xray determines format by file extension — tmp must end with .json
tmp_dir = os.path.dirname(cfg_path)
tmp = os.path.join(tmp_dir, f"cascade-test-{ts}.json")
json.dump(cfg, open(tmp, "w"), indent=2, ensure_ascii=False)
if xray_type == "native":
t = subprocess.run(test_cmd + [tmp], capture_output=True, text=True)
else:
# for docker: copy tmp into container, test, then replace host file
subprocess.run(["docker", "cp", tmp, "montana-xray:/tmp/xray-test.json"], capture_output=True)
t = subprocess.run(["docker", "exec", "montana-xray", "xray", "-test", "-c", "/tmp/xray-test.json"],
capture_output=True, text=True)
if t.returncode != 0:
os.remove(tmp); res.update(ok=False, err="xray test failed: " + t.stderr[-400:])
print(json.dumps(res, ensure_ascii=False)); sys.exit(1)
os.replace(tmp, cfg_path)
subprocess.run(restart_cmd, capture_output=True, text=True); time.sleep(2)
if xray_type == "native":
act = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
else:
r = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", "montana-xray"],
capture_output=True, text=True)
act = "active" if r.stdout.strip() == "true" else "inactive"
res.update(restarted=True, xray_active=act)
if act != "active":
shutil.copy2(bak, cfg_path)
subprocess.run(restart_cmd, capture_output=True, text=True)
res.update(ok=False, err="xray not active after restart; rolled back")
print(json.dumps(res, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -3636,375 +3636,3 @@ Phase A разблокирована. Reference implementation начинает
---
# Сетевой слой со стороны клиента (App-perspective)
Следующие разделы исторически жили в Montana App spec разделах 10-11. Они описывают взаимодействие клиента с сетевым слоем — режимы узла, libp2p настройка с client-side, выбор хостящего узла, mesh integration на iOS/Android.
## 10. Режимы узла
### 10.1 Лёгкий клиент (по умолчанию на мобильном)
Большинство мобильных пользователей — лёгкие клиенты. Приложение не участвует в консенсусе, только использует сеть.
**Что делает лёгкий клиент:**
- Подключается к нескольким полным узлам через libp2p
- Подписывается на потоки proposals (получает новые proposals)
- Валидирует proposals локально (подписи, совпадение `state_root`)
- Поддерживает локальную копию Таблицы аккаунтов для своего аккаунта и контактов (не всю)
- Отправляет операции в сеть через gossip
- Запрашивает данные Content Layer по необходимости
- Верифицирует получаемые данные через хэши
**Чего лёгкий клиент НЕ делает:**
- Не запускает sequential-chain TimeChain
- Не запускает sequential-chain NodeChain
- Не участвует в лотерее
- Не публикует proposals
- Не хранит полную Таблицу аккаунтов
- Не хранит полную историю proposals
**Ресурсы лёгкого клиента:**
- CPU: минимальный (валидация подписей, криптооперации при отправке и получении)
- Сеть: умеренная (потоки proposals, запросы контента)
- Хранилище: несколько MB для существенного состояния, GB для кэша и подписок
- Батарея: оптимизирован для мобильного (фоновая синхронизация с ограничением темпа)
### 10.2 Полный узел на десктопе
Десктоп-версия Montana App может работать как полный узел.
**Включение режима узла:**
1. «Настройки → Дополнительно → Работать как полный узел»
2. Предупреждение о требованиях (минимум 1 ядро, аптайм 24/7, железо)
3. Пользователь подтверждает
4. Приложение запускает дополнительные потоки:
- Поток sequential-chain TimeChain (1 выделенное ядро)
- NodeChain
- Поток валидатора (валидация операций и финализация)
5. Приложение загружает полное состояние (Таблица аккаунтов, Таблица узлов, история proposals)
6. Если у пользователя есть `NodeRegistration` — начинает участвовать в лотерее
**Требования для полного узла:**
- 1 или более ядер CPU
- 16 или более GB RAM
- 500 или более GB диска (растёт со временем)
- Аптайм 24/7 (или близко)
- Стабильное интернет-соединение
- Пропускная способность: минимум 1 Mbps, рекомендуется 10 Mbps и больше
**Участие в сети:**
- Узел получает `chain_length` за каждое окно активности
- При достаточной `chain_length` становится подтверждающим
- Публикует `BundledConfirmation`
- Может участвовать в лотерее
- Зарабатывает Монтана при выигрыше
- Монтана зачисляется в `operator_account` (тот же аккаунт пользователя)
### 10.3 Процесс регистрации узла
Десктоп-пользователь хочет стать узлом:
1. Пользователь запрашивает приглашение от существующего узла (вне сети)
2. Приглашающий узел формирует `NodeInvitation` с публичным ключом приглашённого
3. `NodeInvitation` публикуется и финализируется в сети
4. Пользователь получает уведомление «Вас пригласили стать узлом»
5. Пользователь подтверждает
6. Приложение запускает sequential-chain процесс длиной `vdf_entry_windows = 20 160 окон` (около 14 дней) в фоне
7. После завершения формируется `NodeRegistration` с `proof_endpoint`
8. Пользователь публикует `NodeRegistration` (`operator_account_id = свой account_id`)
9. После финализации — пользователь становится узлом Montana
Sequential-chain процесс — блокирующий. Приложение должно работать непрерывно или продолжать chain extension при каждом запуске. На мобильном это практически невозможно; на десктопе возможно, но требует 24/7 аптайма в течение двух недель.
---
## 11. Сетевой слой
### 11.1 Настройка libp2p
Montana App использует `rust-libp2p` для P2P сетевого слоя.
**Транспортные протоколы:**
- QUIC (основной для мобильного) — поверх UDP, работает через NAT
- TCP (запасной) — для контекстов где QUIC заблокирован
- WebSocket (для веба если появится)
**Мультиплексирование потоков:**
- yamux (стандарт libp2p)
**Безопасность транспорта:**
- Фреймворк Noise для шифрования транспорта
- Используется Noise_XX с ML-KEM-768 (постквантовая адаптация)
- Это шифрование уровня транспорта; шифрование уровня сообщений — отдельное через Double Ratchet
### 11.2 Bootstrap-узлы
При первом запуске приложению нужно найти сеть.
**Механизмы первичного подключения:**
1. **Хардкодированные bootstrap-узлы** — 12 genesis-узлов, зафиксированы в Genesis Decree. Приложение хардкодит их адреса и `account_id`.
2. **Обнаружение через DNS** — записи SRV `_montana._tcp.montana.io` указывают на известные bootstrap-узлы. Приложение делает запрос DNS при старте.
3. **Обмен пирами** — после подключения к одному bootstrap-узлу приложение запрашивает у него список известных пиров и расширяет свой список.
4. **Обнаружение устойчивое к цензуре** — описано в спеке протокола (Transport Obfuscation, ECH и так далее). Для регионов с блокировкой.
### 11.3 Использование Content Request Protocol
Приложение активно использует `ContentRequest` и `ChunkRequest` для всех операций Content Layer.
**Процесс получения blob:**
1. Приложение вычисляет пару `(app_id, data_hash)` нужного blob
2. Приложение проверяет локальный кэш
3. Если нет — `ContentRequest(app_id, data_hash)` одному из подключённых пиров
4. Пир возвращает манифест (если это манифест) или одиночный blob
5. Приложение верифицирует хэш
6. Если это манифест и нужны чанки — последовательные `ChunkRequest(data_hash, chunk_index)`
7. Собранный blob сохраняется в кэше
**Параллельность:**
- Чанки запрашиваются параллельно у нескольких пиров для скорости
- Неудачные запросы переадресуются другим пирам
- Ограничение темпа для предотвращения перегрузки пиров
### 11.4 Участие в DHT
Приложение участвует в Kademlia DHT libp2p.
**Участие лёгкого клиента:**
- Приложение может публиковать свои записи провайдера в DHT (для своих blob)
- Приложение может делать поиск провайдеров в DHT для нужного контента
- Мобильные лёгкие клиенты могут иметь ограниченное участие в DHT (экономия батареи и сети)
**Полный клиент на десктопе:**
- Полное участие в DHT
- Поддержка таблицы маршрутов
- Помощь другим клиентам через реле
### 11.5 Выбор хостящего узла и отказоустойчивость
Применимо к пути участия через аккаунт (пользователь без своего узла, подключается к узлу-хосту через IBT уровень 3, см. спеку протокола раздел «Два пути участия»).
**Проблема.** Клиент с путём участия только через аккаунт зависит от наличия работающего узла-хоста. Если хост уходит (создатель приложения закрылся, узел офлайн, юрисдикционная блокировка, систематический отказ в gossip) — пользователь должен переключиться на другой узел, иначе история AccountChain и ключи становятся бесполезны без сети для подключения. Наивное решение «выбрать узел с самым длинным `chain_length` и держаться за него» создаёт четыре уязвимости: концентрация на топ-N узлов воссоздаёт централизованный хостинг, заранее построенные sybil-узлы могут попадать в топ за месяцы до атаки, eclipse через искажённый bootstrap делает «самый длинный в видимости» равным «самый длинный под управлением атакующего», постоянное прикрепление к одному хосту даёт ему полный социальный граф клиента. Раздел 11.5 закрывает эти угрозы процедурно.
#### 11.5.1 Три стратегии выбора
Клиент выбирает стратегию при настройке, переключается в любой момент через настройки. Спецификация не предписывает стратегию по умолчанию — приложение рекомендует «Авто» для нетехнических пользователей, «Закреплённый» для технически грамотных операторов с собственными узлами или доверенными хостами.
**Стратегия A — Авто.** Клиент автоматически выбирает узлы-хосты по политике с несколькими критериями (см. 11.5.2). Взаимодействие без необходимости разбираться в выборе узлов. Компромисс: пользователь делегирует решение алгоритму клиента.
**Стратегия B — Закреплённый.** Пользователь явно указывает допустимые узлы — собственный узел, узлы доверенных контактов, узлы из community-реестра публичной утилиты (см. 11.5.5). Полный контроль, никакого автоматического выбора. Компромисс: требует от пользователя поддержания актуального белого списка при изменениях в сети.
**Стратегия C — Гибрид.** Авто с ограничениями — белый список (всегда предпочитать эти узлы пока они проходят критерии), чёрный список (никогда не использовать), юрисдикционные фильтры. Компромисс: средняя сложность настройки, средний контроль.
Стратегия формализована в локальной конфигурации:
```
HostSelectionConfig {
strategy enum (Auto | Pinned | Hybrid)
pinned_set []NodeID (для Pinned, Hybrid)
blacklist []NodeID (для Auto, Hybrid)
jurisdiction_filter []CountryCode (опционально, исключаемые юрисдикции)
parallel_connections u8 (1..16, по умолчанию 5)
rotation_period_tau2 u32 (окон τ₂ между ротациями, по умолчанию 1)
require_advisory bool (по умолчанию false; если true — узел должен быть в community-реестре)
}
```
#### 11.5.2 Политика с несколькими критериями (для стратегий «Авто» и «Гибрид»)
Узел попадает в допустимое множество только если выполнены ВСЕ критерии одновременно:
| Критерий | Минимум по умолчанию | Защищает от |
|---|---|---|
| `chain_length ≥ min_chain_length` | 2 × τ₂ (≈ 40 320 окон, ≈ 28 дней непрерывной работы) | неработающих узлов, недавно созданных sybil-узлов |
| `node_age ≥ min_node_age` | 6 × τ₂ (≈ 84 дня от первого сцементированного `BundledConfirmation`) | заранее построенных sybil-узлов специально подготовленных к атаке за короткий период |
| `latency_p95 ≤ max_acceptable_ms` | 2000 мс | мёртвых или недоступных узлов |
| `not_in_blacklist` | — | известных плохих акторов из локального и community-чёрного списка |
| `not_in_jurisdiction_filter` | — | пользовательских предпочтений по юрисдикции |
| `success_rate_last_τ₂ ≥ threshold` | 0.95 | узлов отказывающих в gossip операций конкретного клиента |
Все критерии настраиваемы пользователем; значения по умолчанию безопасны для типичного неагрессивного окружения. Допустимое множество пересчитывается клиентом локально из публично наблюдаемого состояния NodeChain — не требует доверия к третьей стороне. Пересчёт инкрементальный: новое сцементированное `BundledConfirmation` или истёкшая проба задержки → пересчёт затрагивает только относящиеся узлы.
Из допустимого множества клиент выбирает активное множество соединений через **равномерный случайный выбор** размером `parallel_connections`. Случайный выбор — структурная защита от концентрации: даже если один узел объективно «лучше всех» по критериям, вероятность что все клиенты выберут именно его — низкая.
#### 11.5.3 Параллельные соединения и отказоустойчивость
Клиент держит N параллельных соединений к узлам-хостам одновременно (по умолчанию N = 5, диапазон 1..16, выбирает пользователь по компромиссу пропускная способность и избыточность).
**Операции gossip-ятся через все N узлов параллельно.** Цементирование операции не зависит от единичного узла: достаточно чтобы хотя бы один из N включил её в `BundledConfirmation`. Цензура единичным узлом не работает структурно — операция попадёт в сеть через другое соединение. Это превращает «один хост знает всё и может цензурировать» в «N хостов видят часть каждый, цензура требует координации большинства».
**Отказоустойчивость автоматическая.** При падении, таймауте соединения, явном отказе или падении `success_rate` ниже порога — клиент удаляет узел из активного множества, выбирает следующий из допустимого (тот же равномерный случайный выбор из оставшихся), устанавливает соединение. Никакого действия пользователя не требуется. Push на телефон отправляется только при массовом переключении (больше 50% активного множества за короткое время) — индикация системной проблемы, не отдельных ротаций.
**Мягкий чёрный список с отсрочкой.** Узел который систематически отказывает в gossip конкретных операций конкретного клиента (не общий офлайн / перегрузка) — попадает в локальный мягкий чёрный список с экспоненциальной отсрочкой: первое попадание — исключение на τ₁, второе — на 2 × τ₁ и так далее до постоянного локального блокирования после 8 инцидентов в одном τ₂. Мягкий чёрный список локальный (на клиента), не публикуется — это защита клиента, не санкция узлу.
#### 11.5.4 Ротация
Активное множество соединений ротируется по расписанию (по умолчанию раз в τ₂ окон ≈ 14 дней). При ротации: один узел из активного множества заменяется на новый из допустимого, выбранный равномерно случайно. Постепенная ротация (один узел за раз, не всё множество сразу) сохраняет непрерывность gossip и не создаёт всплеск на сетевом обнаружении.
**Защита от утечки метаданных.** Постоянное прикрепление к одному узлу даёт ему полный социальный граф клиента: социальные связи через адреса получателей `Transfer`, каналы через подписки, время активности через временные метки операций, IP через соединение. Ротация размывает граф между несколькими операторами в скользящем окне. После N циклов ротации (например 6 × τ₂ ≈ 84 дня) ни один из ранее использованных узлов не имеет полной картины — каждый видел только часть активности за свой период активного членства.
Ротация выключается пользователем явно (`rotation_period_tau2 = 0`) для случаев где предсказуемость важнее распределения приватности: стабильная корпоративная среда, известный надёжный собственный узел, специфические требования соответствия.
#### 11.5.5 Community-реестр публичной утилиты
Community-поддерживаемый консультативный реестр узлов, которые сами идентифицируют себя как публичная утилита — принимают хостинг любых аккаунтов без платы, без фильтрации контента, без юрисдикционных ограничений. Слой реестра **не часть протокола** (канонический реестр нарушил бы [I-3] — выбор узла стал бы консенсусно-значимым); это слой уровня приложения над протоколом.
**Самоидентификация оператора.** Узел публикует через свой `operator_account` декларацию через стандартный Anchor с фиксированным `app_id = "montana.public_utility"`:
```
PublicUtilityDeclaration {
node_id NodeID
operator_address AccountID
policy_hash hash32 (хэш документа политики)
policy_url строка (где скачать политику открытым текстом)
contact строка (электронная почта или matrix-handle для споров)
declared_at_window u32
signature 3309 B (ML-DSA-65, подпись ключом operator_account)
}
```
Декларация публичная и верифицируемая любым клиентом через стандартную проверку AccountChain. Оператор несёт репутационную ответственность за соответствие декларированной политике: систематические нарушения → исключение из community-реестра.
**Community-реестр.** Список узлов публичной утилиты с историей репутации поддерживается несколькими независимыми maintainer-ами (рекомендация: M = 35 организаций не аффилированных друг с другом). Каждый maintainer подписывает свой список своим keypair. Клиент принимает узел в допустимое множество по критерию реестра если **K из M** maintainer-ов включили его в свой список (по умолчанию K = 2, настраиваемо).
Реестр клиент использует как подсказку для первичного допустимого множества, не как обязательный фильтр. Узел не в реестре, но проходящий политику 11.5.2 — допустим если пользователь не выставил `require_advisory = true`. Это сохраняет permissionless природу сети: новый легитимный узел без одобрения реестром доступен для использования.
#### 11.5.6 Защита при первичном подключении от eclipse
Расширение 11.2 (Bootstrap-узлы) для защиты от случая когда атакующий контролирует источники первичного подключения конкретного клиента. Если все источники первичного подключения контролируются одним актором, политика 11.5.2 применяется к узлам которые видит клиент — но клиент видит только узлы атакующего, и среди них «самая длинная цепочка» = «самая длинная контролируемая атакующим». Защита — структурное первичное подключение из нескольких источников с перекрёстной верификацией.
При первом первичном подключении клиент использует несколько независимых источников одновременно:
- **Хардкодированный список зерен** в дистрибутиве — процесс сборки с несколькими maintainer-ами (не единый корпоративный контроль над релизным артефактом). Минимум 12 узлов из разных юрисдикций
- **Зёрна DNS** на разных провайдерах инфраструктуры — рекомендуется 3 или больше независимых DNS-зон (например `seed.montana.io`, `seed.montana.org`, `seed.montana-network.io`) на разных регистраторах и хостингах
- **Опциональное первичное подключение через Tor** для перекрёстной верификации из регионов с подозрением на сетевую цензуру
- **Опциональный вне-сетевой верифицированный узел** от доверенного контакта — QR-код или ручной ввод `NodeID` и мультиадреса
**Перекрёстные проверки.** Клиент сравнивает представления топологии полученные от разных источников. Если результаты значительно расходятся (больше 50% узлов из одного источника не известны второму, или непересекающиеся распределения `chain_length`) — предупреждение пользователю «возможна атака на обнаружение, проверьте канал первичного подключения» с детализацией расхождения. При совпадающих представлениях — высокая уверенность, переход к нормальной работе.
**Периодическое повторное первичное подключение.** Раз в N × τ₂ (по умолчанию N = 4, ≈ 56 дней) клиент перепроверяет источники первичного подключения для детекции долгой eclipse-атаки. Если новое первичное подключение даёт топологию значительно отличную от текущего допустимого множества — предупреждение и рекомендация пересмотреть активные соединения.
#### 11.5.7 Индикация в интерфейсе
В «Настройки → Сеть → Хостинг аккаунта» клиент показывает:
- Текущую стратегию (Авто / Закреплённый / Гибрид) с краткой пометкой компромисса
- Активное множество соединений: список N узлов с метриками на хост — `latency p95`, `success rate` за последний τ₂, временная метка последнего успешного gossip, `node_age`, `chain_length`, заявленная юрисдикция (если есть в `PublicUtilityDeclaration`), количество одобрений реестра
- Временная метка последней ротации и обратный отсчёт до следующей плановой
- Здоровье источников первичного подключения: количество источников в согласии, временная метка последней перекрёстной проверки, индикаторы расхождения
- Размер допустимого множества (сколько узлов проходят политику 11.5.2 сейчас) — индикатор здоровья сети для данного клиента
Управляющие действия в интерфейсе: принудительная ротация сейчас, переключение стратегии, управление закреплёнными / чёрными списками, аварийный ручной ввод хоста (в случае массового переключения при котором допустимое множество временно пусто), запуск повторного первичного подключения.
При проблемах (массовое переключение, `success_rate` ниже порога по большинству хостов, расхождение первичного подключения) — push на телефон с описанием состояния и рекомендованными действиями. Пользователь не должен узнавать о проблеме хостинга случайно.
### 11.6 Интеграция Mesh Transport
Раздел описывает интеграцию протокольного Mesh Transport (см. раздел Mesh Transport в спеке протокола) в клиентское приложение Montana на уровне нативных платформенных API. Формат MeshFrame, типы кадров, параметры буфера и правила хранения-и-пересылки определены в спеке протокола — здесь не дублируются.
#### 11.6.1 Режимы активации
Mesh-транспорт имеет три режима работы, пользователь выбирает в «Настройки → Сеть → Режим mesh»:
**Выключен (по умолчанию).** Mesh-транспорт не активируется. Приложение работает только через интернет. Рекомендуется для обычного использования — экономит батарею и не расходует радио.
**По требованию.** Mesh активируется автоматически когда приложение обнаруживает отсутствие интернет-соединения. При восстановлении интернета mesh деактивируется, кадры из буфера синхронизируются в сеть через интернет-шлюз. Индикатор в интерфейсе показывает текущий режим (интернет / mesh).
**Всегда включён.** Mesh активен постоянно параллельно с интернетом. Рекомендуется пользователям в контекстах высокого риска (активист, журналист в цензурной юрисдикции) — при внезапном отключении связь не прерывается. Расходует больше батареи (базовый расход ≈ 1525% в сутки в зависимости от устройства).
Пользователь явно соглашается на активацию mesh при первом включении — приложение показывает объяснение: «Режим mesh использует Bluetooth и Wi-Fi Direct для связи когда интернет недоступен. Расход батареи выше. Ваше местоположение не раскрывается приложению, но устройства в радиусе Bluetooth могут видеть факт наличия Montana на вашем телефоне.»
#### 11.6.2 Интеграция iOS
**Фреймворки:**
- `CoreBluetooth` для рекламы и сканирования BLE
- `MultipeerConnectivity` для обнаружения сервисов аналогичного Wi-Fi Direct (высокая пропускная способность для больших сообщений)
**Ограничения фонового режима.** iOS ограничивает фоновые операции Bluetooth:
- Фоновый режим `bluetooth-central` разрешает сканирование в фоне, но с уменьшенной частотой
- Фоновый режим `bluetooth-peripheral` разрешает рекламу в фоне с пониженным приоритетом UUID сервиса
- Полная функциональность mesh — только на активном экране; в фоне — пассивное прослушивание и приоритетная очередь для известных контактов
**`BGTaskScheduler`.** Периодические фоновые задачи запланированы через `BGProcessingTaskRequest` для периодической синхронизации буфера. iOS самостоятельно решает когда запустить задачу; приложение не гарантирует тайминг.
**UUID сервиса:** зарезервированный 16-байтовый UUID для сервиса mesh Montana (зарегистрирован в эталонной реализации), публикуется в данных рекламы BLE.
#### 11.6.3 Интеграция Android
**API:**
- `BluetoothLeAdvertiser` и `BluetoothLeScanner` для кадров BLE mesh
- `WifiP2pManager` для соединений Wi-Fi Direct (высокая пропускная способность)
- `ForegroundService` с уведомлением для долгоживущих операций mesh (Android требует видимого уведомления для фонового непрерывного BLE)
**Меры приватности.** На Android включена рандомизация MAC BLE — платформа ротирует аппаратный MAC каждые 15 минут по умолчанию. Дополнительно Montana ротирует `mesh_session_id` при переходе между сессиями mesh.
**Белый список оптимизации батареи.** При первом включении режима mesh приложение просит пользователя исключить Montana из оптимизатора батареи Android — без этого ОС может агрессивно приостанавливать фоновые операции.
#### 11.6.4 Жизненный цикл сессии
1. **Обнаружение:** приложение транслирует периодические кадры обнаружения (`frame_type = 0`) с базовым темпом. Другие устройства Montana в радиусе их получают.
2. **Совпадение контакта:** если кадр адресован известному контакту (`recipient_hint` совпадает) — приложение инициирует mesh-рукопожатие IBT (см. подраздел **Identity-Bound Tunnel → Mesh transport extension** выше в этой спеке).
3. **Установление сессии:** после успешного рукопожатия сессия установлена, `mesh_session_id` добавлен в локальный список активных mesh-сессий.
4. **Обмен данными:** сообщения чата или blob-ы платежей передаются через кадры данных (`frame_type = 1`) с аутентификацией через MAC сессии.
5. **Пересылка:** кадры не адресованные себе — хранятся в буфере mesh согласно правилам хранения-и-пересылки, оппортунистически пересылаются другим пирам.
6. **Закрытие сессии:** явное «закрыть сессию» пользователем, либо таймаут неактивности 4 часа, либо истечение допустимой устарелости `cached_window_index`.
#### 11.6.5 Роль шлюза
Устройство с одновременным доступом к интернету и mesh действует как **шлюз** между изолированной областью mesh и глобальной сетью Montana:
- Кадры полученные из буфера mesh адресованные `account_id` которые находятся за пределами mesh — пересылаются через интернет-gossip P2P к хостящему узлу получателя
- Кадры полученные через интернет адресованные `account_id` подключённым через mesh — помещаются в локальный буфер mesh для пересылки ближайшими пирами
Пользователь-шлюз может явно включить или отключить режим шлюза в настройках. По умолчанию включён для режима «всегда включён», выключен для «по требованию».
#### 11.6.6 Локальное хранилище
Специфичное для mesh локальное состояние хранится в зашифрованной базе SQLite рядом с остальным состоянием приложения:
```
active_mesh_sessions:
mesh_session_id 32 B (первичный ключ)
peer_pubkey 1952 B (ML-DSA-65)
peer_contact_account_id 32 B (если peer в адресной книге)
session_established_at временная метка
last_activity_at временная метка
session_mac_key 32 B (выведен через HKDF из общего секрета сессии)
cached_peer_window_index u32
mesh_buffer:
frame_hash 32 B (первичный ключ)
frame_bytes blob (сериализованный MeshFrame)
received_at временная метка
ttl_remaining u8
sender_ref 32 B
forwarded_to blob (множество peer-id как сериализованный массив)
mesh_used_nonces:
sender_pubkey 1952 B
nonce 32 B
expires_at временная метка (received_at + 7 × τ₁)
PRIMARY KEY (sender_pubkey, nonce)
```
Мастер-ключ шифрования состояния приложения применим к этим таблицам без отличий.
---
---
## Связанные спецификации
- **Montana Protocol** (родительский слой) — state machine, crypto, consensus.
- **Montana App** (дочерний слой) — UI клиенты на основе этого сетевого API.
История версий читается через git log и `Code/VERSION.md`.
---
*Конец спецификации Montana Network.*

View File

@ -0,0 +1,101 @@
# Montana — VPN Alliance Architecture
**Version:** 1.1.0 (2026-05-26)
**Layer:** Application — a federation pattern over the Egress layer. Defines no consensus state.
---
## Concept
A VPN Alliance is the voluntary federation of Montana nodes that opt into the exit role and insure one another's reachability. Each node is a city; a city opens its own egress; cities insure each other so that a client who cannot reach one city directly still reaches it through a city it can reach. The alliance is the operational expression of the Montana principle that a personal network works when everyone can join: the union of reachable entry points and country exits is the usable surface, and no single blocked address removes a country from that surface.
The alliance is a service of its member operators, not a protocol guarantee. The consensus layer neither requires nor records alliance membership. A node participates fully in consensus and messaging whether or not it joins the alliance.
---
## Why an operator joins
The alliance is addressed to operators who already run paid VPN exits and face the same recurring costs. Membership is concrete operational value, stated without embellishment:
- **Reachability insurance for a blocked address.** When a censor adds an operator's exit IP to a per-network filter, the exit normally becomes unsellable to clients on that network. Inside the alliance the exit stays reachable: a client connects through any member front it can reach, which relays to the operator's exit. The operator's country stays on sale even while its own address is filtered for part of the client base. Removing the country requires filtering every member front, across operators and address ranges — not one address.
- **Entry load is absorbed by partners; the exit monetises egress.** The front carries the client's session as opaque ciphertext and performs no decryption (Circuit Relay v2, end-to-end Noise_PQ XX to the exit). Cryptographic and egress work — the billable resource — runs on the chosen exit, the operator's own server. A member contributes light front capacity to partners and receives light front capacity in return, while each operator's heavy load stays on the exit it sells.
- **Post-quantum transport by default.** The client-to-exit session is Noise_PQ XX (ML-KEM-768 + ML-DSA-65 + ChaCha20-Poly1305). Traffic recorded today is not decryptable by a future quantum adversary. An operator inherits this without implementing post-quantum cryptography in-house.
- **One subscription, instant country listing.** Members that adopt the alliance universal key share one client-facing identity; a new member's country appears in the shared subscription on registration, with no per-client credential exchange. An operator reaches the alliance's existing client base on day one.
- **Self-healing reduces support load.** Per-network blocks are detected and routed around by reachability sensing without operator intervention; a client converges on a working front automatically. Fewer "it stopped working on my ISP" tickets reach the operator.
- **Sovereignty — opt-in, no lock-in.** Membership is voluntary. Each operator keeps its own egress policy, its own pricing, and its own key choice (universal, or an own key when its port is shared with a website). The protocol imposes no reward and takes no cut; an operator leaves by deregistering. The alliance is a cooperation pattern, not an intermediary.
## Membership
A node joins by enabling the exit role (Egress spec, Exit node) and registering an `EgressDirectoryEntry`:
- it advertises a jurisdiction (`country_code`) and a capacity class;
- it adopts either the **alliance universal key** (one Reality keypair shared across members so a single client subscription authenticates to every member) or its **own key** (when its port is shared with another public service, the node masquerades as its own real site);
- it accepts the operator obligations of forwarding third-party traffic (egress policy, jurisdictional exposure).
Membership is opt-in and revocable: a node leaves by deregistering and disabling the exit role. The alliance defines no protocol-level reward to members; an incentive mechanism, if introduced, is specified separately in the monetary layer.
---
## Universal-key federation
Alliance members that adopt the universal key present an identical client-facing identity: the same UUID, public key, short id, and cover SNI. One client subscription therefore authenticates to any member without per-exit credentials. Members that share a port with another public service adopt an own key and masquerade as their own real site; their subscription entry carries that member's distinct public parameters. Both classes coexist in one subscription.
The universal private key is operator-held secret material distributed out of band to alliance members; it never appears in any public artifact. A member node holds it locally to terminate client Reality sessions.
---
## Mutual insurance (the alliance property)
Censorship is per access network: an exit address reachable from one operator is filtered on another. A static one-address-per-country map fails under this. The alliance closes the gap by separating *where the client connects* from *where the client exits*:
```
client → reachable front (any alliance member the client can reach)
→ relay to chosen exit (any alliance member in the target country)
→ clearnet
```
A client picks an exit country; the client connects to a front it can reach; the front relays to the exit. An exit whose own address is blocked from the client's operator remains usable, because the client reaches it through a front that is not blocked. Cities insure each other: the reachability of any one exit is the union of the reachabilities of all fronts that can relay to it. Blocking a country requires blocking every front that can reach its exit — across multiple operators, hosting providers, and address ranges.
### Front load model
The front carries only the relayed byte stream; it does not terminate the client's cryptographic session. The inner Noise_PQ XX session is end-to-end between client and exit (Network → Circuit Relay v2 carries ciphertext only), so the front performs no per-byte decryption and re-encryption. Cryptographic and egress load fall on the chosen exit — the server the client selected — while the front remains a light relay. This is the normative load model; a deployment that terminates and re-originates the session at the front concentrates load on the front and is a degraded fallback, not the target architecture.
---
## Discovery and selection
Members are discovered through the egress directory (Egress spec) and ranked for the client's vantage through reachability sensing (Network spec). The client selects an exit manually (a chosen country) or automatically (the reachability-ranked, lowest-latency reachable exit for its vantage). Selection is client-side and confirmed by a direct IBT handshake to the chosen exit; no front dictates the client's exit.
---
## Resilience
The alliance is available while at least one (reachable front × live exit) pair exists for a requested country. With members across multiple operators, hosting providers, address ranges, and transport profiles (Network → Transport profile ladder), the pair matrix is redundant: an adversary must simultaneously block every front's reachable transport and every exit's path to remove a country. Reachability sensing converges the client onto a working pair without operator intervention; loss of a front mid-session re-steers to another while preserving the exit and its country.
---
## Trust boundary
| Party | Learns | Does not learn |
|-------|--------|----------------|
| Front / relay | the addresses of the hops it connects | egress destinations, payload (inner session is end-to-end) |
| Exit | destinations and payload it forwards; the client's account identity | the client's source address |
| Destination | the exit's egress address | the client's identity and address |
This is the trust boundary of any honest exit and is stated, not eliminated. A client requiring no trusted forwarder runs its own member node as front, relay, and exit simultaneously; no third party then forwards its traffic. Operator-declared `country_code` is advisory and corroborated by directory quorum, not a cryptographic proof of jurisdiction.
---
## Relationship to other specifications
- Roles, directory, control messages, two-session establishment, exit policy: **Montana Egress** specification.
- Reachable-front discovery, transport profile ladder, reachability sensing, Circuit Relay v2 transit, Noise_PQ XX, IBT: **Montana Network** specification.
- Account identity, post-quantum primitives: **Montana Protocol** specification.
The alliance redefines none of these; it is the federation pattern that composes them into a censorship-resilient, country-selectable egress whose load rests on the chosen exit.

View File

@ -59,6 +59,7 @@ The protocol is specified as three layered documents — each independently audi
| 2. Network | [`Montana Network v1.2.0.md`](Montana%20Network%20v1.2.0.md) | libp2p transport, Noise_PQ XX (production), Identity-Bound Tunnel, transport randomness, PeerRecord, mesh transport, sync protocols, network-layer threat model, KAT vectors |
| 3. App | [`Montana App v3.12.0.md`](Montana%20App%20v3.12.0.md) | UI, wallet, messenger (Double Ratchet PQ), channels, contacts, profile, Junona AI agent, browser, premium, application-layer economy |
| 4. Egress | [`Montana Egress v1.0.0.md`](Montana%20Egress%20v1.0.0.md) | clearnet egress over the mesh: entry/relay/exit roles, egress directory, manual/auto country selection, two-session architecture, exit policy, threat model |
| 5. Alliance | [`Montana VPN Alliance v1.1.0.md`](Montana%20VPN%20Alliance%20v1.1.0.md) | federation pattern: universal-key membership, mutual reachability insurance, front-light/exit-heavy load model, resilience |
Layer dependency direction: Protocol (low) ← Network (mid) ← App (high). Each layer depends on layers below it; no upward dependency.

View File

@ -0,0 +1,182 @@
# Montana — Network State Snapshot & Recovery Runbook
**Версия слепка:** 1.1.0
**Дата:** 2026-05-27
**Назначение:** зафиксировать рабочее состояние сети + точные процедуры восстановления + все известные причины падений. ВНУТРЕННИЙ документ (содержит IP и ссылки на секреты — НЕ публиковать в efir369999/Montana по правилу no-IP-in-public).
> Версионирование: при любом изменении топологии/ключей/каскадов — bump версии слепка (1.0.0 → 1.1.0) и обновить соответствующий раздел.
---
## 1. Топология (6 узлов)
| alias | IP | хостинг | роль | сервисы |
|-------|----|---------|------|---------|
| moscow | 176.124.208.93 | Timeweb (RU) | genesis, control-plane, VPN own-key | montana-node :8444, xray :443 (Reality masq montana.quest), nginx :8443/:80, orchestrator :5020, sub-gen :5008 |
| frankfurt | 89.19.208.158 | Timeweb (DE) | genesis, **VPN cascade FRONT**, VPN direct | montana-node :8444, xray :443 (Reality universal) + cascade outbounds |
| helsinki | 91.132.142.42 | THE.Hosting (FI) | genesis, VPN exit | montana-node :8444, xray :443 (Reality universal) |
| yerevan | 149.154.184.205 | WorkTitans (AM) | VPN exit (docker) | docker: montana-node :8444, xray :443, nginx-decoy :80 |
| vilnius | 149.154.185.5 | LT | VPN exit (docker) | docker: montana-node :8444, xray :443, nginx-decoy :80 |
| nicosia | 45.9.13.170 | VMmanager (CY) | VPN exit (docker) | docker: montana-node :8444, xray :443, nginx-decoy :80 |
SSH-алиасы: `montana-moscow`/`my-timeweb`, `montana-frankfurt`, `montana-finland`, `montana-armenia`, `montana-lithuania`, `montana-nicosia`.
---
## 2. Архитектура VPN (на момент слепка)
**Достижимые у домашних RU-провайдеров фронты:** Frankfurt (89.19.208.158), Moscow (176.124.208.93) — диапазоны Timeweb не режутся.
**Заблокированные напрямую у домашних RU-ISP:** Helsinki, Yerevan, Vilnius (диапазоны THE.Hosting / 149.154.0.0/16).
**Решение — каскад:** все заблокированные города ходят через достижимый фронт Frankfurt (raw double-crypto в текущей версии), выход — в правильной стране.
```
клиент → de.montana.quest:443 (Frankfurt, Reality) → routing по cascade-UUID → <out> → exit-страна
```
| Город в подписке | Коннект (host) | UUID | flow | Выход (IP / страна) |
|---|---|---|---|---|
| 🇩🇪 Франкфурт | de.montana.quest | e6d355e2 (universal) | vision | 89.19.208.158 / DE (direct) |
| 🇷🇺 Москва | montana.quest | c83d4d13 (own) | vision | 176.124.208.93 / RU (direct) |
| 🇫🇮 Хельсинки | de.montana.quest | 094f9073 (cascade) | none | 91.132.142.42 / FI (via FRA) |
| 🇦🇲 Ереван | de.montana.quest | 43ba0c0e (cascade) | none | 149.154.184.205 / AM (via FRA) |
| 🇱🇹 Вильнюс | de.montana.quest | fc8a174d (cascade) | none | 149.154.185.5 / LT (via FRA) |
| 🇨🇾 Никосия | de.montana.quest | dad79315 (cascade) | none | 45.9.13.170 / CY (via FRA) |
---
## 3. Ключи Reality
**Universal Montana key** (Helsinki/Frankfurt/Yerevan/Vilnius exit + клиентская подписка):
- UUID `e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d`
- PBK `EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8`
- SID `302805bc0c25e504`
- SNI `www.googletagmanager.com`
- privateKey `cL7D6FCqH5nWcQlHCKH9uNr-RNwCt5peRAqt8tl9mXs` (секрет; на каждом exit-узле в xray config; pre-stage для install: `/etc/montana-vpn/privkey`)
**Moscow own key** (masq под свой сайт montana.quest, т.к. :443 делит с сайтом):
- UUID `c83d4d13-fce9-4c07-85f0-c152d4bda3ee`
- PBK `svxjTnEZxk6aStkaHSYd2b-br3Pe4yqGcNrugokjEgg`
- SID `f976f81b29f78c1f`
- SNI `montana.quest`
- privateKey `iMWS9kMDTBsvRqXMdjXdoRg50DgB3ZRjvJEZ2LxPm3g` (секрет; в xray config Moscow)
**Cascade-UUID** (на Frankfurt-фронте, маршрутизируют к exit-outbound; flow пустой):
- yerevan `43ba0c0e-c1e3-4e30-8ae8-c2e68d24d7c7` → outbound `armenia-out`
- helsinki `094f9073-aff0-4c07-a4af-6ca4c924f6a9` → outbound `helsinki-out`
- vilnius `fc8a174d-f42b-4945-8548-ab5c9f448f81` → outbound `vilnius-out`
- nicosia `dad79315-0b80-5eca-9703-afee839e0131` → outbound `nicosia-out`
Orchestrator admin token: Moscow `/etc/montana/orchestrator-admin-token`. CF API token: Keychain `cloudflare-api-token` / `montana-quest`.
---
## 4. Cloudflare DNS (зона montana.quest, все DNS-only, не proxied)
```
fi.montana.quest → 91.132.142.42 (Helsinki)
de.montana.quest → 89.19.208.158 (Frankfurt — cascade front)
am.montana.quest → 149.154.184.205 (Yerevan)
lt.montana.quest → 149.154.185.5 (Vilnius)
cy.montana.quest → 45.9.13.170 (Nicosia)
cdn.montana.quest → multi-A: 89.19.208.158, 91.132.142.42, 149.154.185.5, 149.154.184.205, 45.9.13.170 (watchdog auto-prune)
montana.quest → 176.124.208.93 (Moscow — сайт + own-key VPN)
```
---
## 5. Control-plane (Moscow)
- **orchestrator** `/opt/montana-orchestrator/server.py`, systemd `montana-orchestrator.service`, :5020. Registry `/var/lib/montana-orchestrator/nodes.json`. API `/register /deregister /nodes /pool` (нужен `secret`=admin token). Watchdog probe Reality :443 каждые 30с, prune dead из cdn multi-A.
- **sub-генератор** `/opt/montana-vpn-balance/app.py`, gunicorn :5008, отдаёт `/vpn/sub`. Карты: `ALIAS_HOST`, `CASCADE` (yerevan/helsinki/vilnius → de.montana.quest), `OWN_KEY` (moscow → montana.quest). Ключи из `/etc/montana-vpn/keys.json` (sync с Helsinki, таймер `montana-vpn-key-sync`).
- **nginx** :443 → SNI-роутинг montana.quest+hub+efir; ВНИМАНИЕ: на Moscow xray держит :443, nginx на 127.0.0.1:8443 (Reality dest=local nginx). Все enabled на boot.
- **сайт-данные**: build-скрипты `montana-net-pull`, `montana-cities-build`, `aggregator.sh`, `collector.sh`, `peer-health.py` (на mos/fra/fin). US удалён отовсюду.
---
## 6. Frankfurt cascade-config
```
inbound: reality-in :443 (universal key)
clients: montana-universal(vision), yerevan-cascade(none), helsinki-cascade(none), vilnius-cascade(none), nicosia-cascade(none)
outbounds: direct, blocked, dns-out, armenia-out(→149.154.184.205:443), helsinki-out(→91.132.142.42:443), vilnius-out(→149.154.185.5:443), nicosia-out(→45.9.13.170:443)
routing: user=<cascade-email> → соответствующий <city>-out (Reality client universal к exit)
```
Бэкапы конфига: `/usr/local/etc/xray/config.json.bak-*` (последний рабочий — без dokodemo).
---
## 7. ВСЕ известные причины падений + восстановление
### 7.1 Frankfurt xray restart-race (СЛУЧАЛОСЬ 2026-05-26)
**Симптом:** xray `failed`, journal `bind: address already in use`, `Start request repeated too quickly`. Все каскады + клиентский VPN вниз.
**Причина:** правка live-конфига (новый inbound/порт) + `Restart=always` → зомби-инстанс держит порт, новый не биндит, systemd крутит рестарты до лимита.
**Восстановление:**
```
ssh montana-frankfurt 'systemctl stop xray; pkill -9 xray; for p in 443 2096 2097 2098; do fuser -k $p/tcp; done; sleep 3; \
cp $(ls -t /usr/local/etc/xray/config.json.bak-* | head -1) /usr/local/etc/xray/config.json; \
xray -test -config /usr/local/etc/xray/config.json; systemctl reset-failed xray; systemctl start xray'
```
**Профилактика:** НЕ править live-xray на проде хирургией; правильный путь load-распределения — deploy mt-egress relay, не dokodemo на живой xray.
### 7.2 ufw блокирует :8444 (узел не пирится входящими)
**Симптом:** montana-node только исходящие ESTAB, входящих нет; не виден peer-ам.
**Причина:** install-vps-full поднимал ufw с 22/80/443, забывал 8444.
**Восстановление:** `ssh <node> 'ufw allow 8444/tcp; ufw reload'`. (install-docker.sh уже открывает 8444.)
### 7.3 Reality-ключи рассинхрон (только Frankfurt работал)
**Симптом:** клиент TLS-handshake проходит, но прокси не работает на части узлов; работает только один город.
**Причина:** privateKey на узле ≠ PBK в подписке.
**Восстановление:** сверить `xray x25519 -i <priv>` каждого узла → PBK должен == подписочный. Universal privkey `cL7D6FCq…` на всех exit. Команда сверки:
```
ssh <node> 'docker exec montana-xray xray x25519 -i $(jq -r .inbounds[0].streamSettings.realitySettings.privateKey /etc/montana-vpn/xray-config.json)'
```
### 7.4 Узел недостижим у конкретного провайдера (IP в РКН-блоке)
**Симптом:** город не открывается у одного ISP, работает у других / из дата-центра.
**Причина:** IP узла в блок-листе оператора (149.154.0.0/16 = легаси-Telegram; THE.Hosting). НЕ баг сервера.
**Восстановление:** завернуть город каскадом через достижимый фронт (Frankfurt/Moscow) — см. §2. Долгосрочно: T1T3 транспорты (spec B1, не готовы) либо чистый IP.
### 7.5 Moscow :443 конфликт (сайт vs VPN)
**Симптом:** упал сайт montana.quest ИЛИ /vpn/sub ИЛИ orchestrator.
**Причина:** xray и nginx делят :443; неверный порядок старта / занятый порт.
**Восстановление:** nginx должен слушать 127.0.0.1:8443 (не :443); xray :443 dest=127.0.0.1:8443. Бэкапы nginx: `/root/nginx-bak-*`. Проверка: `curl -sk --resolve montana.quest:443:127.0.0.1 https://montana.quest/vpn/sub` → 200.
### 7.6 docker build OOM / провал (Armenia/Vilnius при install)
**Симптом:** install-docker.sh падает на cargo build.
**Причины:** (а) RAM < 1.5G без swap OOM; (б) `Montana wordlist.txt` вне build-context (фикс: context = repo root); (в) glibc mismatch (фикс: runtime debian:trixie-slim); (г) volume permission (фикс: chown+runuser); (д) xray x25519 формат вывода (фикс: awk `/Password|ublic/`).
**Восстановление:** все фиксы уже в install-docker.sh main; при OOM добавить swap.
### 7.7 cdn.montana.quest содержит мёртвый/блокируемый IP
**Симптом:** «Auto» попадает на нерабочий узел.
**Причина:** watchdog probe только с Moscow (один vantage) — держит IP, недостижимый у клиента.
**Восстановление:** deregister руками: `curl -X POST -d '{"ip":"<ip>","secret":"<token>"}' https://montana.quest/vpn/node/deregister`. Долгосрочно: reachability-sensing (новый код mt-net, не задеплоен).
---
## 8. Команды проверки здоровья (health-check)
```
# узлы (фаза + пиры)
for n in moscow frankfurt finland armenia lithuania; do ssh montana-$n '...montana-node status...'; done
# подписка (5 городов)
curl -sk https://montana.quest/vpn/sub | base64 -d
# каскады выходят правильной страной (с Moscow)
ssh montana-moscow 'bash /tmp/test-all-cascades.sh' # helsinki→FI, vilnius→LT, yerevan→AM
# Reality :443 фронта извне
echo Q | openssl s_client -connect de.montana.quest:443 -servername www.googletagmanager.com -brief
# orchestrator
curl -sk https://montana.quest/vpn/node/health
```
---
## 9. Что задеплоено vs что в коде (на момент слепка)
- **Прод (живое):** montana-node v1.0.0 (mos/fra/fin/am/lt/cy), статический каскад через Frankfurt, install-docker.sh (авто-join + VPN-ключи).
- **В коде, НЕ задеплоено:** mt-net reachability/steering (M10 A-C), mt-egress (M11 A-B + client + e2e) — юнит-протестировано локально (30+ тестов), вшивание в montana-node (M11 C) pending.
- **Не построено:** T1-T3 транспорты (spec B1), English-перевод Network спеки (B5), M7 fast-sync, M8 multi-node apply, авто-апдейт флота.