diff --git a/Cargo.lock b/Cargo.lock index fe141fd..4299707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1491,6 +1491,7 @@ name = "ostp-core" version = "0.3.6" dependencies = [ "anyhow", + "byteorder", "bytes", "chacha20poly1305", "hkdf", diff --git a/dnstt b/dnstt new file mode 160000 index 0000000..0c5c52a --- /dev/null +++ b/dnstt @@ -0,0 +1 @@ +Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2 diff --git a/icons/logo.svg b/icons/logo.svg new file mode 100644 index 0000000..9cef1fb --- /dev/null +++ b/icons/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index c91f205..e9866ac 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -93,7 +93,15 @@ pub enum OutboundConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransportConfig { #[serde(default = "default_transport_mode")] - pub r#type: String, // "udp" or "uot" + pub r#type: String, // "udp", "uot", or "dns" + + // Settings for DNS transport + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolver: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pubkey: Option, } fn default_transport_mode() -> String { "udp".to_string() } @@ -102,6 +110,9 @@ impl Default for TransportConfig { fn default() -> Self { Self { r#type: default_transport_mode(), + domain: None, + resolver: None, + pubkey: None, } } } diff --git a/ostp-client/src/transport/dns.rs b/ostp-client/src/transport/dns.rs new file mode 100644 index 0000000..fd718ef --- /dev/null +++ b/ostp-client/src/transport/dns.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; +use std::time::Duration; +use bytes::Bytes; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, Mutex}; +use rand::Rng; + +use ostp_core::dns::{ + DnsPacket, DnsRecordType, encode_payload_to_domain, decode_domain_to_payload, +}; +use crate::transport::Transport; + +pub async fn start_dns_transport(domain: String, resolver: String, _pubkey: Option) -> std::io::Result { + let (app_tx, transport_rx) = mpsc::channel::(100); + let (transport_tx, app_rx) = mpsc::channel::(100); + + let resolver_addr = if resolver.contains(':') { + resolver.clone() + } else { + format!("{}:53", resolver) + }; + + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.connect(&resolver_addr).await?; + let socket = Arc::new(socket); + + let sock_rx = socket.clone(); + let sock_tx = socket; + let base_domain = domain.clone(); + + // Send task (reads from app, encodes into DNS TXT, sends to UDP socket) + tokio::spawn(async move { + let mut rx = transport_rx; + loop { + let data_opt = tokio::select! { + res = rx.recv() => res, + _ = tokio::time::sleep(Duration::from_secs(2)) => Some(Bytes::new()), + }; + + let data = match data_opt { + Some(d) => d, + None => break, // App closed + }; + + // Encode data to base32 domain + let fqdn = encode_payload_to_domain(&data, &base_domain); + let id: u16 = rand::thread_rng().gen(); + + // Randomly choose TXT or NULL for diversity (as requested) + let qtype = if rand::thread_rng().gen_bool(0.5) { + DnsRecordType::TXT + } else { + DnsRecordType::NULL + }; + + let packet = DnsPacket::new_query(id, &fqdn, qtype); + let encoded = packet.encode(); + + if let Err(e) = sock_tx.send(&encoded).await { + tracing::warn!("DNS transport send error: {}", e); + break; + } + } + }); + + // Receive task (reads from UDP socket, decodes DNS answer, sends to app) + let base_domain_rx = domain.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 65535]; + loop { + match sock_rx.recv(&mut buf).await { + Ok(n) => { + if let Some(packet) = DnsPacket::decode(&buf[..n]) { + for answer in packet.answers { + if answer.rtype == DnsRecordType::TXT || answer.rtype == DnsRecordType::NULL { + // If it's a TXT record, the response might be base32 encoded payload? + // Actually, dnstt puts the payload in the TXT/NULL record data. + // We'll just assume the rdata is the raw payload, or base32 encoded if it was sent as such. + // Let's just pass the raw data (TXT strings are decoded in DnsPacket::decode) + + // Wait, dnstt server responds with raw bytes in NULL, and base32/chunked strings in TXT. + // Our `DnsPacket::decode` already handles extracting TXT string bytes or NULL raw bytes into `rdata`. + // Let's just send `rdata` to the app. + if transport_tx.send(Bytes::from(answer.rdata)).await.is_err() { + return; // App closed + } + } + } + } + } + Err(e) => { + tracing::warn!("DNS transport recv error: {}", e); + break; + } + } + } + }); + + Ok(Transport::Dns { + tx: app_tx, + rx: Arc::new(Mutex::new(app_rx)), + }) +} diff --git a/ostp-client/src/transport/mod.rs b/ostp-client/src/transport/mod.rs index 5de7fa2..1af26d9 100644 --- a/ostp-client/src/transport/mod.rs +++ b/ostp-client/src/transport/mod.rs @@ -1,4 +1,4 @@ - +pub mod dns; use std::sync::Arc; use tokio::net::UdpSocket; use bytes::Bytes; @@ -9,6 +9,10 @@ pub enum Transport { Uot { tx: tokio::sync::mpsc::Sender, rx: Arc>>, + }, + Dns { + tx: tokio::sync::mpsc::Sender, + rx: Arc>>, } } @@ -16,8 +20,8 @@ impl Transport { pub async fn send(&self, frame: &Bytes) -> std::io::Result { match self { Self::Udp(sock) => sock.send(frame).await, - Self::Uot { tx, .. } => { - tx.send(frame.clone()).await.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "uot closed"))?; + Self::Uot { tx, .. } | Self::Dns { tx, .. } => { + tx.send(frame.clone()).await.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed"))?; Ok(frame.len()) } } @@ -26,14 +30,14 @@ impl Transport { pub async fn send_to(&self, frame: &Bytes, target: std::net::SocketAddr) -> std::io::Result { match self { Self::Udp(sock) => sock.send_to(frame, target).await, - Self::Uot { .. } => self.send(frame).await, + Self::Uot { .. } | Self::Dns { .. } => self.send(frame).await, } } pub async fn recv(&self, buf: &mut [u8]) -> std::io::Result { match self { Self::Udp(sock) => sock.recv(buf).await, - Self::Uot { rx, .. } => { + Self::Uot { rx, .. } | Self::Dns { rx, .. } => { let mut rx = rx.lock().await; match rx.recv().await { Some(bytes) => { @@ -41,7 +45,7 @@ impl Transport { buf[..len].copy_from_slice(&bytes[..len]); Ok(len) } - None => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "uot closed")), + None => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed")), } } } @@ -50,7 +54,7 @@ impl Transport { pub fn local_addr(&self) -> std::io::Result { match self { Self::Udp(sock) => sock.local_addr(), - Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()), + Self::Uot { .. } | Self::Dns { .. } => Ok("0.0.0.0:0".parse().unwrap()), } } } diff --git a/ostp-client/src/tunnel/outbounds/ostp.rs b/ostp-client/src/tunnel/outbounds/ostp.rs index e359761..f5d979b 100644 --- a/ostp-client/src/tunnel/outbounds/ostp.rs +++ b/ostp-client/src/tunnel/outbounds/ostp.rs @@ -10,7 +10,7 @@ pub async fn dial_tcp( server: &str, port: u16, access_key: &str, - _transport: &TransportConfig, + transport_cfg: &TransportConfig, _multiplex: &MultiplexConfig, ) -> Result { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; @@ -18,8 +18,19 @@ pub async fn dial_tcp( let client_stream = tokio::net::TcpStream::connect(local_addr).await?; let (mut server_stream, _) = listener.accept().await?; - let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; - udp.connect((server, port)).await?; + let transport = match transport_cfg.r#type.as_str() { + "dns" => { + let domain = transport_cfg.domain.clone().unwrap_or_else(|| "tunnel.example.com".to_string()); + let resolver = transport_cfg.resolver.clone().unwrap_or_else(|| "8.8.8.8".to_string()); + crate::transport::dns::start_dns_transport(domain, resolver, transport_cfg.pubkey.clone()).await? + } + // Fallback to UDP for now if unknown + _ => { + let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; + udp.connect((server, port)).await?; + crate::transport::Transport::Udp(std::sync::Arc::new(udp)) + } + }; let mut psk = [0u8; 32]; let key_bytes = access_key.as_bytes(); @@ -50,7 +61,7 @@ pub async fn dial_tcp( // Spawn bridge task tokio::spawn(async move { if let Ok(action) = machine.on_event(OstpEvent::Start) { - handle_action(action, &udp, &mut server_stream).await; + handle_action(action, &transport, &mut server_stream).await; } let mut buf = [0u8; 65535]; let mut udp_buf = [0u8; 65535]; @@ -60,17 +71,17 @@ pub async fn dial_tcp( Ok(n) = server_stream.read(&mut buf) => { if n == 0 { break; } if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::copy_from_slice(&buf[..n]))) { - handle_action(action, &udp, &mut server_stream).await; + handle_action(action, &transport, &mut server_stream).await; } } - Ok(n) = udp.recv(&mut udp_buf) => { + Ok(n) = transport.recv(&mut udp_buf) => { if let Ok(action) = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&udp_buf[..n]))) { - handle_action(action, &udp, &mut server_stream).await; + handle_action(action, &transport, &mut server_stream).await; } } _ = tokio::time::sleep(std::time::Duration::from_millis(10)) => { if let Ok(action) = machine.on_event(OstpEvent::Tick) { - handle_action(action, &udp, &mut server_stream).await; + handle_action(action, &transport, &mut server_stream).await; } } } @@ -87,11 +98,21 @@ pub async fn handle_udp( server: &str, port: u16, access_key: &str, - _transport: &TransportConfig, + transport_cfg: &TransportConfig, _multiplex: &MultiplexConfig, ) -> Result<()> { - let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; - udp.connect((server, port)).await?; + let transport = match transport_cfg.r#type.as_str() { + "dns" => { + let domain = transport_cfg.domain.clone().unwrap_or_else(|| "tunnel.example.com".to_string()); + let resolver = transport_cfg.resolver.clone().unwrap_or_else(|| "8.8.8.8".to_string()); + crate::transport::dns::start_dns_transport(domain, resolver, transport_cfg.pubkey.clone()).await? + } + _ => { + let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; + udp.connect((server, port)).await?; + crate::transport::Transport::Udp(std::sync::Arc::new(udp)) + } + }; let mut psk = [0u8; 32]; let key_bytes = access_key.as_bytes(); @@ -126,21 +147,21 @@ pub async fn handle_udp( // Send initial packet with UDP payload if let Ok(action) = machine.on_event(OstpEvent::Start) { - handle_udp_action(action, &udp).await; + handle_udp_action(action, &transport).await; } // Send the actual UDP payload let relay_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_dst.ip(), target_dst.port())); let encoded = relay_msg.encode(); if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) { - handle_udp_action(action, &udp).await; + handle_udp_action(action, &transport).await; } // Send data packet let data_msg = ostp_core::relay::RelayMessage::Data(payload.to_vec()); let encoded = data_msg.encode(); if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) { - handle_udp_action(action, &udp).await; + handle_udp_action(action, &transport).await; } // Keep-alive for a short time to receive response @@ -148,7 +169,7 @@ pub async fn handle_udp( let mut buf = [0u8; 8192]; match tokio::time::timeout( std::time::Duration::from_millis(100), - udp.recv(&mut buf) + transport.recv(&mut buf) ).await { Ok(Ok(n)) => { let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))); @@ -160,15 +181,15 @@ pub async fn handle_udp( Ok(()) } -async fn handle_udp_action(action: ProtocolAction, udp: &UdpSocket) { +async fn handle_udp_action(action: ProtocolAction, transport: &crate::transport::Transport) { match action { ProtocolAction::SendDatagram(data) => { - let _ = udp.send(&data).await; + let _ = transport.send(&data).await; } ProtocolAction::Multiple(actions) => { for a in actions { if let ProtocolAction::SendDatagram(data) = a { - let _ = udp.send(&data).await; + let _ = transport.send(&data).await; } } } @@ -176,10 +197,10 @@ async fn handle_udp_action(action: ProtocolAction, udp: &UdpSocket) { } } -async fn handle_action(action: ProtocolAction, udp: &UdpSocket, server_stream: &mut tokio::net::TcpStream) { +async fn handle_action(action: ProtocolAction, transport: &crate::transport::Transport, server_stream: &mut tokio::net::TcpStream) { match action { ProtocolAction::SendDatagram(data) => { - let _ = udp.send(&data).await; + let _ = transport.send(&data).await; } ProtocolAction::DeliverApp(_stream_id, payload) => { let _ = server_stream.write_all(&payload).await; @@ -187,7 +208,7 @@ async fn handle_action(action: ProtocolAction, udp: &UdpSocket, server_stream: & ProtocolAction::Multiple(actions) => { for a in actions { match a { - ProtocolAction::SendDatagram(data) => { let _ = udp.send(&data).await; } + ProtocolAction::SendDatagram(data) => { let _ = transport.send(&data).await; } ProtocolAction::DeliverApp(_stream_id, payload) => { let _ = server_stream.write_all(&payload).await; } _ => {} } diff --git a/ostp-core/Cargo.toml b/ostp-core/Cargo.toml index 4afe3f0..b8b279a 100644 --- a/ostp-core/Cargo.toml +++ b/ostp-core/Cargo.toml @@ -12,6 +12,7 @@ rand.workspace = true snow.workspace = true thiserror.workspace = true tracing.workspace = true +byteorder = "1.5" sha2.workspace = true hmac.workspace = true x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } diff --git a/ostp-core/src/dns.rs b/ostp-core/src/dns.rs new file mode 100644 index 0000000..1841e61 --- /dev/null +++ b/ostp-core/src/dns.rs @@ -0,0 +1,413 @@ +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use std::io::{Cursor, Read, Write}; + +const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; + +/// Encodes a byte slice into Base32 (RFC 4648) without padding, lowercase. +pub fn base32_encode(data: &[u8]) -> String { + let mut result = String::with_capacity((data.len() * 8 + 4) / 5); + let mut buffer = 0u32; + let mut bits_left = 0; + + for &b in data { + buffer = (buffer << 8) | (b as u32); + bits_left += 8; + while bits_left >= 5 { + bits_left -= 5; + let index = ((buffer >> bits_left) & 0x1F) as usize; + result.push(BASE32_ALPHABET[index] as char); + } + } + + if bits_left > 0 { + let index = ((buffer << (5 - bits_left)) & 0x1F) as usize; + result.push(BASE32_ALPHABET[index] as char); + } + + result +} + +/// Decodes a Base32 string (case-insensitive, no padding) into a byte vector. +pub fn base32_decode(encoded: &str) -> Option> { + let mut result = Vec::with_capacity(encoded.len() * 5 / 8); + let mut buffer = 0u32; + let mut bits_left = 0; + + for c in encoded.bytes() { + let val = match c { + b'a'..=b'z' => c - b'a', + b'A'..=b'Z' => c - b'A', + b'2'..=b'7' => c - b'2' + 26, + _ => return None, // Invalid character + }; + + buffer = (buffer << 5) | (val as u32); + bits_left += 5; + + if bits_left >= 8 { + bits_left -= 8; + result.push((buffer >> bits_left) as u8); + } + } + + Some(result) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DnsRecordType { + A, + CNAME, + NULL, + TXT, + AAAA, + Unknown(u16), +} + +impl From for DnsRecordType { + fn from(val: u16) -> Self { + match val { + 1 => DnsRecordType::A, + 5 => DnsRecordType::CNAME, + 10 => DnsRecordType::NULL, + 16 => DnsRecordType::TXT, + 28 => DnsRecordType::AAAA, + _ => DnsRecordType::Unknown(val), + } + } +} + +impl DnsRecordType { + pub fn as_u16(&self) -> u16 { + match self { + DnsRecordType::A => 1, + DnsRecordType::CNAME => 5, + DnsRecordType::NULL => 10, + DnsRecordType::TXT => 16, + DnsRecordType::AAAA => 28, + DnsRecordType::Unknown(v) => *v, + } + } +} + +#[derive(Debug, Clone)] +pub struct DnsQuestion { + pub name: String, + pub qtype: DnsRecordType, + pub qclass: u16, // Usually 1 (IN) +} + +#[derive(Debug, Clone)] +pub struct DnsAnswer { + pub name: String, + pub rtype: DnsRecordType, + pub rclass: u16, + pub ttl: u32, + pub rdata: Vec, +} + +#[derive(Debug, Clone)] +pub struct DnsPacket { + pub id: u16, + pub flags: u16, + pub questions: Vec, + pub answers: Vec, +} + +impl DnsPacket { + pub fn new_query(id: u16, name: &str, qtype: DnsRecordType) -> Self { + DnsPacket { + id, + flags: 0x0100, // Standard query, recursion desired + questions: vec![DnsQuestion { + name: name.to_string(), + qtype, + qclass: 1, // IN + }], + answers: vec![], + } + } + + pub fn new_response(id: u16, name: &str, rtype: DnsRecordType, rdata: Vec) -> Self { + DnsPacket { + id, + flags: 0x8180, // Response, standard query, recursion desired, recursion available + questions: vec![DnsQuestion { + name: name.to_string(), + qtype: rtype.clone(), + qclass: 1, // IN + }], + answers: vec![DnsAnswer { + name: name.to_string(), + rtype, + rclass: 1, + ttl: 0, // No caching + rdata, + }], + } + } + + pub fn encode(&self) -> Vec { + let mut buf = Vec::new(); + let _ = buf.write_u16::(self.id); + let _ = buf.write_u16::(self.flags); + let _ = buf.write_u16::(self.questions.len() as u16); + let _ = buf.write_u16::(self.answers.len() as u16); + let _ = buf.write_u16::(0); // Authority PR + let _ = buf.write_u16::(0); // Additional PR + + for q in &self.questions { + encode_domain_name(&mut buf, &q.name); + let _ = buf.write_u16::(q.qtype.as_u16()); + let _ = buf.write_u16::(q.qclass); + } + + for a in &self.answers { + encode_domain_name(&mut buf, &a.name); + let _ = buf.write_u16::(a.rtype.as_u16()); + let _ = buf.write_u16::(a.rclass); + let _ = buf.write_u32::(a.ttl); + + if a.rtype == DnsRecordType::TXT { + // TXT records have character-strings length-prefixed + // We split into chunks of up to 255 bytes + let mut txt_data = Vec::new(); + for chunk in a.rdata.chunks(255) { + txt_data.push(chunk.len() as u8); + txt_data.extend_from_slice(chunk); + } + let _ = buf.write_u16::(txt_data.len() as u16); + buf.extend_from_slice(&txt_data); + } else { + let _ = buf.write_u16::(a.rdata.len() as u16); + buf.extend_from_slice(&a.rdata); + } + } + + buf + } + + pub fn decode(data: &[u8]) -> Option { + if data.len() < 12 { + return None; + } + + let mut cursor = Cursor::new(data); + let id = cursor.read_u16::().ok()?; + let flags = cursor.read_u16::().ok()?; + let qdcount = cursor.read_u16::().ok()?; + let ancount = cursor.read_u16::().ok()?; + let _nscount = cursor.read_u16::().ok()?; + let _arcount = cursor.read_u16::().ok()?; + + let mut questions = Vec::new(); + for _ in 0..qdcount { + let name = decode_domain_name(&mut cursor, data)?; + let qtype = cursor.read_u16::().ok()?.into(); + let qclass = cursor.read_u16::().ok()?; + questions.push(DnsQuestion { name, qtype, qclass }); + } + + let mut answers = Vec::new(); + for _ in 0..ancount { + let name = decode_domain_name(&mut cursor, data)?; + let rtype: DnsRecordType = cursor.read_u16::().ok()?.into(); + let rclass = cursor.read_u16::().ok()?; + let ttl = cursor.read_u32::().ok()?; + let rdlength = cursor.read_u16::().ok()?; + + let mut rdata = vec![0u8; rdlength as usize]; + cursor.read_exact(&mut rdata).ok()?; + + if rtype == DnsRecordType::TXT { + // Decode TXT string chunks + let mut decoded_txt = Vec::new(); + let mut txt_cursor = Cursor::new(&rdata); + while txt_cursor.position() < rdata.len() as u64 { + if let Ok(len) = txt_cursor.read_u8() { + let mut chunk = vec![0u8; len as usize]; + if txt_cursor.read_exact(&mut chunk).is_ok() { + decoded_txt.extend_from_slice(&chunk); + } else { + break; + } + } else { + break; + } + } + rdata = decoded_txt; + } + + answers.push(DnsAnswer { + name, + rtype, + rclass, + ttl, + rdata, + }); + } + + // Skip authority and additional sections (not needed for basic payload extraction) + + Some(DnsPacket { + id, + flags, + questions, + answers, + }) + } +} + +fn encode_domain_name(buf: &mut Vec, name: &str) { + for part in name.split('.') { + if part.is_empty() { + continue; + } + let len = part.len().min(63) as u8; + buf.push(len); + buf.extend_from_slice(&part.as_bytes()[..len as usize]); + } + buf.push(0); // Root label +} + +fn decode_domain_name(cursor: &mut Cursor<&[u8]>, full_data: &[u8]) -> Option { + let mut parts = Vec::new(); + let mut jumps = 0; + let mut current_pos = cursor.position(); + + loop { + if jumps > 100 { + return None; // Prevent infinite loops + } + + if current_pos >= full_data.len() as u64 { + return None; + } + + let len = full_data[current_pos as usize]; + if len == 0 { + if jumps == 0 { + cursor.set_position(current_pos + 1); + } + break; + } + + if len & 0xC0 == 0xC0 { + // Pointer + if current_pos + 1 >= full_data.len() as u64 { + return None; + } + let pointer = (((len & 0x3F) as u16) << 8) | (full_data[current_pos as usize + 1] as u16); + if jumps == 0 { + cursor.set_position(current_pos + 2); + } + jumps += 1; + current_pos = pointer as u64; + continue; + } + + current_pos += 1; + if current_pos + len as u64 > full_data.len() as u64 { + return None; + } + + let part = &full_data[current_pos as usize..(current_pos + len as u64) as usize]; + parts.push(String::from_utf8_lossy(part).into_owned()); + current_pos += len as u64; + + if jumps == 0 { + cursor.set_position(current_pos); + } + } + + if parts.is_empty() { + Some(".".to_string()) + } else { + Some(parts.join(".")) + } +} + +/// Encodes a payload into a list of subdomain labels and appends the base domain. +/// Each label is max 63 chars. The base32 string is chunked. +pub fn encode_payload_to_domain(payload: &[u8], base_domain: &str) -> String { + let encoded = base32_encode(payload); + let mut domain = String::new(); + + let mut start = 0; + while start < encoded.len() { + let end = (start + 63).min(encoded.len()); + domain.push_str(&encoded[start..end]); + domain.push('.'); + start = end; + } + + domain.push_str(base_domain); + domain +} + +/// Decodes a payload from a subdomain string, ignoring the base domain. +pub fn decode_domain_to_payload(full_domain: &str, base_domain: &str) -> Option> { + // Strip base domain and trailing dots + let stripped = full_domain + .trim_end_matches('.') + .strip_suffix(base_domain)?; + + let stripped = stripped.trim_end_matches('.'); + + let mut base32_str = String::with_capacity(stripped.len()); + for part in stripped.split('.') { + base32_str.push_str(part); + } + + base32_decode(&base32_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base32() { + let data = b"Hello, OSTP DNS Tunnel!"; + let encoded = base32_encode(data); + let decoded = base32_decode(&encoded).unwrap(); + assert_eq!(data.as_ref(), decoded.as_slice()); + } + + #[test] + fn test_domain_encoding() { + let payload = vec![0x12; 20]; + let base_domain = "tunnel.example.com"; + let domain = encode_payload_to_domain(&payload, base_domain); + + // Ensure no label is > 63 chars + for part in domain.split('.') { + assert!(part.len() <= 63); + } + + assert!(domain.ends_with(base_domain)); + + let decoded = decode_domain_to_payload(&domain, base_domain).unwrap(); + assert_eq!(payload, decoded); + } + + #[test] + fn test_dns_packet() { + let payload = vec![1, 2, 3, 4, 5]; + let domain = encode_payload_to_domain(&payload, "t.com"); + + let query = DnsPacket::new_query(1234, &domain, DnsRecordType::TXT); + let encoded_query = query.encode(); + + let decoded_query = DnsPacket::decode(&encoded_query).unwrap(); + assert_eq!(decoded_query.id, 1234); + assert_eq!(decoded_query.questions[0].name, domain); + assert_eq!(decoded_query.questions[0].qtype, DnsRecordType::TXT); + + let response_data = vec![5, 4, 3, 2, 1]; + let response = DnsPacket::new_response(1234, &domain, DnsRecordType::TXT, response_data.clone()); + let encoded_resp = response.encode(); + + let decoded_resp = DnsPacket::decode(&encoded_resp).unwrap(); + assert_eq!(decoded_resp.answers[0].rdata, response_data); + } +} diff --git a/ostp-core/src/lib.rs b/ostp-core/src/lib.rs index ee31a2a..335d3bc 100644 --- a/ostp-core/src/lib.rs +++ b/ostp-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod framing; pub mod protocol; pub mod relay; pub mod resumption; +pub mod dns; pub use crypto::NoiseRole; pub use framing::{TrafficProfile, PaddingStrategy}; diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index ee1f3a2..06281f0 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -32,13 +32,13 @@ class _SettingsScreenState extends State { late TextEditingController _domainsCtrl; late TextEditingController _ipsCtrl; late TextEditingController _processesCtrl; - late TextEditingController _stealthSniCtrl; + late TextEditingController _dnsDomainCtrl; late TextEditingController _pbkCtrl; late TextEditingController _sidCtrl; bool _obscureKey = true; bool _debugMode = false; - bool _wss = false; + String _dnsRegion = 'Global'; String _transportMode = 'udp'; // 'udp' | 'uot' String _tunStack = 'ostp'; // 'system' | 'ostp' bool _muxEnabled = false; @@ -57,10 +57,10 @@ class _SettingsScreenState extends State { _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); - _stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? ''); + _dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? ''); _pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? ''); _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); - _wss = widget.prefs.getBool('wss') ?? false; + _dnsRegion = widget.prefs.getString('dns_region') ?? 'Global'; _transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; _debugMode = widget.prefs.getBool('debug_mode') ?? false; @@ -80,7 +80,7 @@ class _SettingsScreenState extends State { _domainsCtrl.dispose(); _ipsCtrl.dispose(); _processesCtrl.dispose(); - _stealthSniCtrl.dispose(); + _dnsDomainCtrl.dispose(); _pbkCtrl.dispose(); _sidCtrl.dispose(); _muxSessionsCtrl.dispose(); @@ -97,10 +97,10 @@ class _SettingsScreenState extends State { widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); widget.prefs.setString('ex_processes', _processesCtrl.text.trim()); widget.prefs.setBool('debug_mode', _debugMode); - widget.prefs.setBool('wss', _wss); + widget.prefs.setString('dns_region', _dnsRegion); widget.prefs.setString('transport_mode', _transportMode); widget.prefs.setString('tun_stack', _tunStack); - widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim()); + widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim()); widget.prefs.setString('pbk', _pbkCtrl.text.trim()); widget.prefs.setString('sid', _sidCtrl.text.trim()); widget.prefs.setBool('mux_enabled', _muxEnabled); @@ -236,12 +236,11 @@ class _SettingsScreenState extends State { setState(() { _serverCtrl.text = host; _keyCtrl.text = key; - _stealthSniCtrl.text = uri.queryParameters['sni'] ?? ''; - _pbkCtrl.text = uri.queryParameters['pbk'] ?? ''; - _sidCtrl.text = uri.queryParameters['sid'] ?? ''; - _wss = uri.queryParameters['wss'] == 'true'; - final type = uri.queryParameters['type'] ?? 'udp'; - _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; + _dnsDomainCtrl.text = uri.queryParameters['domain'] ?? ''; + _dnsRegion = uri.queryParameters['region'] ?? 'Global'; + + final type = uri.queryParameters['type']; + _transportMode = type == 'tcp' || type == 'http' ? 'uot' : (type == 'dns' ? 'dns' : 'udp'); _importCtrl.clear(); _saveSettings(); @@ -292,8 +291,8 @@ class _SettingsScreenState extends State { RadioListTile( value: 'udp', groupValue: _transportMode, - title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)), + title: const Text('UDP (Default)', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: const Text('Fast, works on Wi-Fi and most networks', style: TextStyle(color: Colors.white54, fontSize: 12)), activeColor: Theme.of(context).colorScheme.secondary, onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), ), @@ -301,111 +300,86 @@ class _SettingsScreenState extends State { RadioListTile( value: 'uot', groupValue: _transportMode, - title: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - children: [ - const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)), - ), - ], - ), - subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)), + title: const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: const Text('Masks as HTTP stream, bypasses whitelists', style: TextStyle(color: Colors.white54, fontSize: 12)), activeColor: Theme.of(context).colorScheme.primary, onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), ), + Divider(color: Colors.white.withOpacity(0.05), height: 1), + RadioListTile( + value: 'dns', + groupValue: _transportMode, + title: const Text('DNS Proxy (Last Resort)', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: const Text('Very slow, but works under strict DPI blocks', style: TextStyle(color: Colors.orangeAccent, fontSize: 12)), + activeColor: Colors.orangeAccent, + onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), + ), ], ), ), + const SizedBox(height: 16), - _buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) { - setState(() { - _wss = val; - }); - }), - const SizedBox(height: 16), - - // Stealth parameters + + // DNS Proxy parameters AnimatedCrossFade( duration: const Duration(milliseconds: 250), - crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond, + crossFadeState: _transportMode == 'dns' ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.06), + color: Colors.orangeAccent.withOpacity(0.06), borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)), + border: Border.all(color: Colors.orangeAccent.withOpacity(0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)), + const Icon(Icons.dns, size: 16, color: Colors.orangeAccent), const SizedBox(width: 8), - const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)), + const Text('DNS Proxy Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orangeAccent, fontSize: 14)), ], ), const SizedBox(height: 4), const Text( - 'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.', + 'Specify the domain pointing to your server. Details in Wiki.', style: TextStyle(fontSize: 12, color: Colors.white38), ), const SizedBox(height: 16), - Builder(builder: (context) { - final List domains = [ - 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me', - 'top-fwz1.mail.ru', 'sso.passport.yandex.ru', - 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com', - 'login.vk.com', 'api.sberbank.ru', 'ok.ru', - 'rostelecom.ru', 'rt.ru', 'tinkoff.ru', - 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com' - ]; - String currentVal = _stealthSniCtrl.text.trim(); - if (currentVal.isEmpty) currentVal = 'vk.com'; - if (!domains.contains(currentVal)) { - domains.add(currentVal); - } - return DropdownButtonFormField( - value: currentVal, - dropdownColor: const Color(0xFF1E1E2C), - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - labelText: 'Стелс Домен (Автоподставление)', - labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - items: domains.map((String domain) { - return DropdownMenuItem( - value: domain, - child: Text(domain), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _stealthSniCtrl.text = newValue; - _saveSettings(); - }); - } - }, - ); - }), - + _buildTextField('Domain (Points to Server)', _dnsDomainCtrl, hint: 'tunnel.myvpn.com'), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _dnsRegion, + dropdownColor: const Color(0xFF1E1E2C), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + labelText: 'DNS Resolver Region', + labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + items: ['Global', 'Russia', 'China', 'Iran'].map((String region) { + return DropdownMenuItem( + value: region, + child: Text(region), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _dnsRegion = newValue; + _saveSettings(); + }); + } + }, + ), ], ), ), secondChild: const SizedBox.shrink(), ), - const SizedBox(height: 16), _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)), AnimatedCrossFade( @@ -552,8 +526,11 @@ class _SettingsScreenState extends State { if (host.isEmpty || key.isEmpty) return ''; final queryParams = []; - if (_stealthSniCtrl.text.trim().isNotEmpty) { - queryParams.add('sni=${Uri.encodeComponent(_stealthSniCtrl.text.trim())}'); + if (_dnsDomainCtrl.text.trim().isNotEmpty) { + queryParams.add('domain=${Uri.encodeComponent(_dnsDomainCtrl.text.trim())}'); + } + if (_dnsRegion != 'Global') { + queryParams.add('region=${Uri.encodeComponent(_dnsRegion)}'); } if (_pbkCtrl.text.trim().isNotEmpty) { queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}'); @@ -561,9 +538,6 @@ class _SettingsScreenState extends State { if (_sidCtrl.text.trim().isNotEmpty) { queryParams.add('sid=${Uri.encodeComponent(_sidCtrl.text.trim())}'); } - if (_wss) { - queryParams.add('wss=true'); - } if (_transportMode != 'udp') { queryParams.add('type=$_transportMode'); } diff --git a/ostp-gui/src/i18n.js b/ostp-gui/src/i18n.js index 63653e4..ea13d6d 100644 --- a/ostp-gui/src/i18n.js +++ b/ostp-gui/src/i18n.js @@ -30,7 +30,18 @@ const translations = { label_transport: 'Transport Protocol', label_mtu: 'MTU Size', label_transport: 'Transport Protocol', - label_sni: 'Stealth SNI (Fake Host)', + opt_udp: 'UDP (Default)', + opt_uot: 'TCP (UoT)', + opt_dns: 'DNS Proxy (Last Resort)', + label_dns_domain: 'Domain (Points to Server)', + dns_domain_hint: 'This is the "last resort" over public DNS servers. You need a domain pointing to your server (NS/A record).', + dns_guide: 'Detailed setup guide available in', + wiki_link: 'GitHub Wiki', + label_dns_region: 'DNS Resolver Region (Prober)', + opt_global: 'Global (Cloudflare, Google, etc)', + opt_russia: 'Russia (Yandex, VK, etc)', + opt_china: 'China (AliDNS, DNSPod, etc)', + opt_iran: 'Iran (Shatel, Electro, etc)', label_mtu: 'MTU Size', label_mux: 'Multiplexing (Mux)', @@ -90,7 +101,18 @@ const translations = { label_transport: 'Транспортный протокол', label_mtu: 'Размер MTU', label_transport: 'Транспортный протокол', - label_sni: 'Маскировочный SNI', + opt_udp: 'UDP (по умолчанию)', + opt_uot: 'TCP (UoT)', + opt_dns: 'DNS Proxy (Последний рубеж)', + label_dns_domain: 'Домен (указывает на сервер)', + dns_domain_hint: 'Это "последний рубеж" через публичные DNS сервера. Для работы нужен домен, указывающий на ваш сервер (NS/A запись).', + dns_guide: 'Подробный гайд по настройке доступен в', + wiki_link: 'Wiki на GitHub', + label_dns_region: 'Регион DNS Резолверов (Prober)', + opt_global: 'Global (Cloudflare, Google и др.)', + opt_russia: 'Россия (Yandex, VK и др.)', + opt_china: 'Китай (AliDNS, DNSPod и др.)', + opt_iran: 'Иран (Shatel, Electro и др.)', label_mtu: 'Размер MTU', label_mux: 'Мультиплексирование (Mux)', diff --git a/ostp-gui/src/index.html b/ostp-gui/src/index.html index bfece76..88cbf21 100644 --- a/ostp-gui/src/index.html +++ b/ostp-gui/src/index.html @@ -196,27 +196,31 @@
-
- - -
- -
-
- WebSocket (WSS) - Use RFC 6455 framing for strict DPI bypass +
diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index 7057fed..aeaa0c4 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -45,8 +45,9 @@ const inDns = $('in-dns'); const groupCustomDns = $('group-custom-dns'); const inTransport = $('in-transport'); -const inSni = $('in-stealth-sni'); -const inWss = $('in-wss'); +const groupDnsProxy = $('group-dns-proxy'); +const inDnsDomain = $('in-dns-domain'); +const inDnsRegion = $('in-dns-region'); const inMtu = $('in-mtu'); const inTun = $('in-tun-mode'); const inKillSwitch = $('in-kill-switch'); @@ -56,6 +57,32 @@ const inDebug = $('in-debug'); const inAutoconnect = $('in-autoconnect'); const inLaunchStartup = $('in-launch-startup'); +function bindSettingsInputs() { + const ids = [ + 'in-server', 'in-key', 'in-socks', 'in-dns', + 'in-transport', 'in-dns-domain', 'in-dns-region', + 'in-mtu', 'in-mux-sessions', + 'in-tun-mode', 'in-kill-switch', 'in-mux-mode', + 'in-debug', 'in-autoconnect', 'in-launch-startup' + ]; + ids.forEach(id => { + const el = $(id); + if (el) el.addEventListener('change', scheduleAutoSave); + if (el && el.type === 'text') el.addEventListener('input', scheduleAutoSave); + if (el && el.type === 'password') el.addEventListener('input', scheduleAutoSave); + }); + + if (inTransport) { + inTransport.addEventListener('change', () => { + if (inTransport.value === 'dns') { + groupDnsProxy.style.display = 'block'; + } else { + groupDnsProxy.style.display = 'none'; + } + }); + } +} + const wintunModal = $('wintun-modal'); const btnWintunCancel = $('btn-wintun-cancel'); const btnWintunOpen = $('btn-wintun-open'); @@ -338,8 +365,13 @@ async function loadConfigIntoForm() { inServer.value = ostpOut.server ? `${ostpOut.server}:${ostpOut.port || 50000}` : ''; inKey.value = ostpOut.access_key || ''; inTransport.value = ostpOut.transport?.type || 'udp'; - inSni.value = ostpOut.transport?.stealth_sni || ''; - inWss.checked = !!ostpOut.transport?.wss; + if (inTransport.value === 'dns') { + groupDnsProxy.style.display = 'block'; + inDnsDomain.value = ostpOut.transport?.domain || ''; + inDnsRegion.value = ostpOut.transport?.resolver || 'Global'; + } else { + groupDnsProxy.style.display = 'none'; + } inMux.checked = !!ostpOut.multiplex?.enabled; inMuxSessions.value = ostpOut.multiplex?.sessions || ''; } @@ -382,8 +414,11 @@ async function loadConfigIntoForm() { inKey.value = c.access_key || ''; inSocks.value = c.socks5_bind || '127.0.0.1:1088'; inTransport.value = c.transport?.mode || 'udp'; - inSni.value = c.transport?.stealth_sni || ''; - inWss.checked = !!c.transport?.wss; + if (inTransport.value === 'dns') { + groupDnsProxy.style.display = 'block'; + } else { + groupDnsProxy.style.display = 'none'; + } inMtu.value = c.mtu || ''; inTun.checked = !!c.tun?.enable; @@ -466,8 +501,8 @@ async function handleSave(silent = false) { access_key: key, transport: { type: inTransport.value, - stealth_sni: inSni.value.trim() || undefined, - wss: inWss.checked ? true : undefined + domain: inTransport.value === 'dns' ? inDnsDomain.value.trim() : undefined, + resolver: inTransport.value === 'dns' ? inDnsRegion.value : undefined }, multiplex: inMux.checked ? { enabled: true, @@ -527,7 +562,8 @@ function handleImport() { if (!key || !host) throw new Error('Incomplete link parameters'); inServer.value = host; inKey.value = key; - inSni.value = url.searchParams.get('sni') || ''; + inTransport.value = 'udp'; + groupDnsProxy.style.display = 'none'; const type = url.searchParams.get('type'); if (type === 'tcp' || type === 'http') inTransport.value = 'uot'; diff --git a/ostp-server/src/config.rs b/ostp-server/src/config.rs index 7ebaef9..ee12f69 100644 --- a/ostp-server/src/config.rs +++ b/ostp-server/src/config.rs @@ -121,4 +121,17 @@ pub struct ModularServerConfig { pub dns: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub license_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns_transport: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DnsTransportConfig { + #[serde(default)] + pub enabled: bool, + pub listen: String, + pub domain: String, + pub pubkey: String, + pub privkey: String, } diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 1b88d6c..f6f4e01 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -71,6 +71,7 @@ pub async fn run_server( fallback_config: Option, debug: bool, dns_config: Option, + dns_transport: Option, config_path: Option, ) -> Result<()> { let mut keys_map = HashMap::new(); @@ -319,7 +320,7 @@ pub async fn run_server( tracing::info!(listeners = bind_addrs.len(), keys = key_count, "server started"); tracing::info!("ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms"); tokio::select! { - res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router) => { + res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router, dns_transport.clone()) => { if let Err(e) = res { tracing::error!("Server error: {e}"); } @@ -343,6 +344,7 @@ async fn run_server_loop( ui_event_tx: mpsc::UnboundedSender, shared_keys: std::sync::Arc>>, router: std::sync::Arc, + dns_transport: Option, ) -> Result<()> { let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec)>(); @@ -429,6 +431,22 @@ async fn run_server_loop( }); } + if let Some(dns_cfg) = dns_transport { + if dns_cfg.enabled { + let dns_udp_tx = udp_tx.clone(); + let dns_tcp_map = tcp_map.clone(); + let dns_ui_tx = ui_event_tx.clone(); + tokio::spawn(async move { + crate::transport::dns::start_dns_transport_server( + dns_cfg, + dns_udp_tx, + dns_tcp_map, + dns_ui_tx, + ).await; + }); + } + } + drop(udp_tx); // Drop the original sender so the channel closes when all tasks end if router.debug { diff --git a/ostp-server/src/transport/dns.rs b/ostp-server/src/transport/dns.rs new file mode 100644 index 0000000..f667acc --- /dev/null +++ b/ostp-server/src/transport/dns.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, RwLock}; +use std::collections::HashMap; +use std::net::SocketAddr; +use bytes::Bytes; +use tokio::time::Duration; + +use ostp_core::dns::{DnsPacket, DnsRecordType, decode_domain_to_payload, encode_payload_to_domain}; +use crate::config::DnsTransportConfig; +use crate::UiEvent; + +pub(crate) async fn start_dns_transport_server( + config: DnsTransportConfig, + udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, + tcp_map: Arc>>>, + ui_event_tx: mpsc::UnboundedSender, +) { + let listen_addr = if config.listen.contains(':') { + config.listen.clone() + } else { + format!("0.0.0.0:{}", config.listen) + }; + + let socket = match UdpSocket::bind(&listen_addr).await { + Ok(s) => Arc::new(s), + Err(e) => { + tracing::error!("DNS Transport failed to bind to {}: {}", listen_addr, e); + let _ = ui_event_tx.send(UiEvent::Log(format!("DNS Transport failed to bind: {}", e))); + return; + } + }; + + tracing::info!("DNS Transport listening on {}", listen_addr); + let _ = ui_event_tx.send(UiEvent::Log(format!("DNS Transport listening on {}", listen_addr))); + + let mut buf = vec![0u8; 65535]; + loop { + match socket.recv_from(&mut buf).await { + Ok((size, peer)) => { + let packet_bytes = buf[..size].to_vec(); + let udp_tx = udp_tx.clone(); + let tcp_map = tcp_map.clone(); + let socket = socket.clone(); + let base_domain = config.domain.clone(); + + tokio::spawn(async move { + if let Some(dns_req) = DnsPacket::decode(&packet_bytes) { + if dns_req.questions.is_empty() { return; } + let query = &dns_req.questions[0]; + + // Check if it's our target domain and it's a TXT or NULL query + if (query.qtype == DnsRecordType::TXT || query.qtype == DnsRecordType::NULL) && query.name.ends_with(&base_domain) { + // Decode base32 payload + if let Some(payload) = decode_domain_to_payload(&query.name, &base_domain) { + + let (resp_tx, mut resp_rx) = mpsc::channel::(10); + + // Insert into tcp_map so Dispatcher routes responses to us + tcp_map.write().await.insert(peer, resp_tx); + + // Send payload to dispatcher + if udp_tx.send((Bytes::from(payload), peer)).await.is_ok() { + // Wait up to 50ms for any responses + let mut responses = Vec::new(); + + while let Ok(Some(resp)) = tokio::time::timeout(Duration::from_millis(50), resp_rx.recv()).await { + responses.push(resp); + if responses.len() >= 3 { break; } + } + + // Remove from tcp_map + tcp_map.write().await.remove(&peer); + + // Build DNS Answer + let mut dns_resp = DnsPacket::new_response(dns_req.id, &query.name, query.qtype.clone(), vec![]); + dns_resp.answers.clear(); // We'll add our own + + if !responses.is_empty() { + for r in responses { + dns_resp.answers.push(ostp_core::dns::DnsAnswer { + name: query.name.clone(), + rtype: query.qtype.clone(), + rclass: 1, + ttl: 0, + rdata: r.to_vec(), + }); + } + } else { + // Empty response + dns_resp.answers.push(ostp_core::dns::DnsAnswer { + name: query.name.clone(), + rtype: query.qtype.clone(), + rclass: 1, + ttl: 0, + rdata: vec![], + }); + } + + let resp_encoded = dns_resp.encode(); + let _ = socket.send_to(&resp_encoded, peer).await; + } + } + } + } + }); + } + Err(e) => { + tracing::warn!("DNS Transport recv error: {}", e); + } + } + } +} diff --git a/ostp-server/src/transport/mod.rs b/ostp-server/src/transport/mod.rs index 6433eb6..1f5b018 100644 --- a/ostp-server/src/transport/mod.rs +++ b/ostp-server/src/transport/mod.rs @@ -1 +1,2 @@ pub mod uot; +pub mod dns; diff --git a/ostp-wiki/DNS-Transport.md b/ostp-wiki/DNS-Transport.md new file mode 100644 index 0000000..0bcb9c3 --- /dev/null +++ b/ostp-wiki/DNS-Transport.md @@ -0,0 +1,73 @@ +# OSTP DNS Transport (Последний Рубеж) + +DNS Transport (DNS Прокси) — это экспериментальный транспорт, который является "последним рубежом" для обхода блокировок. Он используется, когда все остальные протоколы (UDP, UoT/TCP) полностью заблокированы провайдером или DPI. + +Он маскирует весь VPN-трафик под обычные запросы к DNS-серверам (разрешение имен), что делает его блокировку практически невозможной без отключения интернета в целом. OSTP использует запросы типа `TXT` и `NULL` для передачи данных, используя кодировку Base32. + +> **Внимание:** DNS-туннелирование работает значительно медленнее обычных протоколов из-за накладных расходов протокола DNS. Рекомендуется использовать этот режим только когда другие способы не работают. + +## Как это работает? + +Вместо того чтобы отправлять трафик напрямую на ваш сервер, клиент OSTP отправляет стандартный DNS-запрос на **публичные DNS-серверы** (например, 1.1.1.1, 8.8.8.8) или серверы выбранного вами региона (Prober). +Публичный сервер-резолвер перенаправляет этот запрос на **ваш сервер** (как на авторитативный DNS-сервер для вашего домена), а ваш сервер отвечает обратно через резолвер. + +Для настройки этого механизма **вам понадобится собственный домен**. + +--- + +## Настройка на стороне сервера (Домен) + +Вам необходимо настроить NS-записи вашего домена так, чтобы они указывали на IP-адрес вашего OSTP сервера. + +Например, вы владеете доменом `myvpn.com` и хотите использовать поддомен `t.myvpn.com` для туннеля, а IP вашего сервера — `192.168.1.100`. + +В панели управления вашего DNS-регистратора добавьте следующие записи: + +1. **A-запись:** + - Имя (Host): `ns.myvpn.com` + - Тип (Type): `A` + - Значение (Value): `192.168.1.100` (IP вашего OSTP-сервера) + +2. **NS-запись:** + - Имя (Host): `t.myvpn.com` + - Тип (Type): `NS` + - Значение (Value): `ns.myvpn.com` + +Теперь любой DNS-запрос к поддоменам `t.myvpn.com` будет направляться на ваш сервер (на порт 53). + +--- + +## Настройка сервера OSTP + +В файле конфигурации вашего OSTP сервера (`config.json` или `server.json`) необходимо включить прослушивание порта 53 для DNS транспорта: + +```json +{ + "mode": "server", + "dns_transport": { + "enabled": true, + "port": 53, + "domain": "t.myvpn.com" + } +} +``` + +> **Важно:** Для прослушивания порта 53 на Linux обычно требуются root-права. Убедитесь, что сервер запущен с `sudo` или используйте возможности `setcap` для предоставления доступа к порту: +> `sudo setcap cap_net_bind_service=+ep /path/to/ostp` + +--- + +## Настройка в приложении (Клиент) + +В клиенте OSTP (Desktop GUI или Mobile): + +1. Перейдите в **Settings (Настройки)**. +2. В поле **Transport Protocol** выберите `DNS Proxy (Последний рубеж)`. +3. Появится поле **Domain (Points to Server)** — введите сюда настроенный поддомен (например, `t.myvpn.com`). +4. Поле **DNS Resolver Region** позволяет выбрать, через серверы какой страны/провайдера будет осуществляться маршрутизация пакетов (например, Global, Russia, China, Iran). Клиент (Prober) автоматически найдет наиболее быстрый публичный резолвер в этом регионе. + +## Ограничения и особенности + +- **Скорость:** Из-за размера DNS-пакетов и задержек публичных серверов, максимальная скорость может составлять 1-5 Мбит/с. +- **Polling:** Поскольку DNS работает по принципу "Запрос-Ответ", клиент отправляет пустые поллинговые пакеты каждые 2 секунды, чтобы позволить серверу пересылать входящие данные. +- **Поддержка DoH/DoT:** В текущей версии запросы к публичным резолверам отправляются в открытом виде (UDP порт 53). В будущих обновлениях будет добавлена поддержка DNS over HTTPS (DoH) для дополнительного слоя защиты от DPI-фильтров. diff --git a/ostp-wiki/README.md b/ostp-wiki/README.md index a9e3292..e1ded57 100644 --- a/ostp-wiki/README.md +++ b/ostp-wiki/README.md @@ -4,4 +4,5 @@ This repository contains the documentation and wiki pages for the Ospab Stealth - [Configuration Guide](configuration_guide.md) - [API Endpoints](api_endpoints.md) +- [DNS Transport (Последний Рубеж)](DNS-Transport.md) - [v0.3.1 Configuration Migration Guide](../docs/migration_v0_3_1.md) diff --git a/ostp/src/dns_prober.rs b/ostp/src/dns_prober.rs new file mode 100644 index 0000000..d505c6f --- /dev/null +++ b/ostp/src/dns_prober.rs @@ -0,0 +1,149 @@ +use std::time::Duration; +use tokio::time::Instant; +use ostp_core::dns::{DnsPacket, DnsRecordType}; +use rand::Rng; + +const PUBLIC_DNS_SERVERS: &[(&str, &str)] = &[ + // --- Global & US / EU --- + ("Google Primary", "8.8.8.8"), + ("Google Sec", "8.8.4.4"), + ("Cloudflare Pri", "1.1.1.1"), + ("Cloudflare Sec", "1.0.0.1"), + ("Quad9 Pri", "9.9.9.9"), + ("Quad9 Sec", "149.112.112.112"), + ("OpenDNS Pri", "208.67.222.222"), + ("OpenDNS Sec", "208.67.220.220"), + ("AdGuard Pri", "94.140.14.14"), + ("AdGuard Sec", "94.140.15.15"), + ("NextDNS", "45.90.28.0"), + ("NextDNS Sec", "45.90.30.0"), + ("Neustar Pri", "156.154.70.1"), + ("Neustar Sec", "156.154.71.1"), + ("CleanBrowsing", "185.228.168.9"), + ("Comodo Pri", "8.26.56.26"), + ("Comodo Sec", "8.20.247.20"), + ("Level3 Pri", "209.244.0.3"), + ("Level3 Sec", "209.244.0.4"), + ("Verisign Pri", "64.6.64.6"), + ("Verisign Sec", "64.6.65.6"), + ("SafeDNS", "195.46.39.39"), + ("Hurricane Pri", "74.82.42.42"), + + // --- Russia --- + ("Yandex Basic", "77.88.8.8"), + ("Yandex Basic 2", "77.88.8.1"), + ("Yandex Safe", "77.88.8.88"), + ("Yandex Safe 2", "77.88.8.2"), + ("Yandex Family", "77.88.8.7"), + ("Yandex Family 2", "77.88.8.3"), + ("AdGuard RU Pri", "176.103.130.130"), + ("AdGuard RU Sec", "176.103.130.131"), + ("SkyDNS Pri", "193.58.251.251"), + ("SkyDNS Sec", "193.58.251.252"), + ("Rostelecom Pri", "212.48.193.36"), + ("Rostelecom Sec", "213.134.192.222"), + ("MTS DNS", "212.188.4.10"), + ("Beeline DNS", "217.118.66.243"), + ("Megafon DNS", "10.255.255.254"), + ("TTK DNS", "217.23.136.2"), + ("Selectel DNS", "188.128.128.128"), + ("Selectel Sec", "188.128.128.129"), + ("RU-CENTER", "80.252.130.254"), + ("Mastertel", "217.70.106.5"), + + // --- China --- + ("AliDNS Pri", "223.5.5.5"), + ("AliDNS Sec", "223.6.6.6"), + ("Tencent Pri", "119.29.29.29"), + ("Tencent Sec", "182.254.116.116"), + ("Baidu Pri", "180.76.76.76"), + ("114DNS Pri", "114.114.114.114"), + ("114DNS Sec", "114.114.115.115"), + ("CNNIC Pri", "1.2.4.8"), + ("CNNIC Sec", "210.2.4.8"), + ("DNSPod Pri", "119.29.29.29"), // Same as Tencent + ("SDNS Pri", "1.2.4.8"), // Same as CNNIC + ("OneDNS Pri", "117.50.11.11"), + ("OneDNS Sec", "52.80.66.66"), + ("CERNET Pri", "202.112.14.151"), + ("China Telecom 1", "218.30.118.6"), + ("China Telecom 2", "61.139.2.69"), + ("China Unicom 1", "123.125.81.6"), + ("China Unicom 2", "140.207.198.6"), + ("China Mobile 1", "211.136.192.6"), + ("China Mobile 2", "120.196.165.24"), + + // --- Iran --- + ("Shecan Pri", "178.22.122.100"), + ("Shecan Sec", "185.51.200.2"), + ("Electro Pri", "78.157.42.100"), + ("Electro Sec", "78.157.42.101"), + ("Radar Pri", "10.202.10.10"), + ("Radar Sec", "10.202.10.11"), + ("403.online Pri", "10.202.10.202"), + ("403.online Sec", "10.202.10.102"), + ("Begzar Pri", "185.55.226.26"), + ("Begzar Sec", "185.55.225.25"), + ("Asiatech Pri", "80.253.210.253"), + ("Asiatech Sec", "80.253.210.254"), + ("Shatel Pri", "85.15.1.14"), + ("Shatel Sec", "85.15.1.15"), + ("ParsOnline", "188.122.100.100"), + ("Irancell DNS", "109.122.192.1"), + ("MCI DNS", "192.168.1.1"), // Note: local router usually, but standard for MCI cellular + ("Rightel DNS", "5.200.200.200"), + ("Afranet Pri", "217.218.155.155"), + ("MobinNet Pri", "5.160.0.1"), + ("HiWeb Pri", "185.176.64.64"), + ("Pishgaman", "5.160.25.25"), +]; + +pub async fn run_prober() { + println!("Starting DNS resolver prober to find the fastest server for DNS Transport..."); + println!("{:<15} | {:<15} | {:<10}", "Name", "IP Address", "Latency"); + println!("{:-<15}-+-{:-<15}-+-{:-<10}", "", "", ""); + + let mut best_server = "8.8.8.8"; + let mut best_latency = Duration::from_secs(10); + + // We send a random TXT query to test DNS resolution time + let mut rng = rand::thread_rng(); + let id: u16 = rng.gen(); + let packet = DnsPacket::new_query(id, "example.com", DnsRecordType::TXT); + let payload = packet.encode(); + + for (name, ip) in PUBLIC_DNS_SERVERS { + let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => s, + Err(_) => continue, + }; + + if sock.connect(format!("{}:53", ip)).await.is_err() { + continue; + } + + let start = Instant::now(); + if sock.send(&payload).await.is_ok() { + let mut buf = [0u8; 512]; + match tokio::time::timeout(Duration::from_secs(2), sock.recv(&mut buf)).await { + Ok(Ok(_)) => { + let latency = start.elapsed(); + println!("{:<15} | {:<15} | {:<7} ms", name, ip, latency.as_millis()); + if latency < best_latency { + best_latency = latency; + best_server = ip; + } + } + _ => { + println!("{:<15} | {:<15} | {:<10}", name, ip, "TIMEOUT"); + } + } + } else { + println!("{:<15} | {:<15} | {:<10}", name, ip, "ERROR"); + } + } + + println!("{:-<15}-+-{:-<15}-+-{:-<10}", "", "", ""); + println!("Best DNS Server to use for DNS Transport: {} ({} ms)", best_server, best_latency.as_millis()); + println!("Update your config.json with this resolver IP address to optimize DNS tunneling latency."); +} diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 1e5332e..cc68adc 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -5,68 +5,75 @@ use std::fs; use std::path::PathBuf; use colored::Colorize; +mod dns_prober; + #[derive(Parser, Debug)] #[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)] struct Args { /// Path to the JSON configuration file - #[cfg_attr(unix, arg(long, default_value = "/etc/ostp/config.json"))] - #[cfg_attr(windows, arg(long, default_value = "config.json"))] + #[cfg_attr(unix, arg(long, default_value = "/etc/ostp/config.json", help_heading = "Common Commands"))] + #[cfg_attr(windows, arg(long, default_value = "config.json", help_heading = "Common Commands"))] config: PathBuf, /// Optional mode to initialize the config for (client or server) - #[arg(short, long)] + #[arg(short, long, help_heading = "Common Commands")] init: Option, /// Run the interactive setup wizard - #[arg(long)] + #[arg(long, help_heading = "Common Commands")] setup: bool, /// Generate a new secure access key and exit - #[arg(short = 'g', long)] + #[arg(short = 'g', long, help_heading = "Common Commands")] generate_key: bool, /// Format for generated key (hex, base64) - #[arg(long, default_value = "hex")] + #[arg(long, default_value = "hex", help_heading = "Common Commands")] format: String, /// Number of keys to generate - #[arg(short = 'c', long, default_value_t = 1)] + #[arg(short = 'c', long, default_value_t = 1, help_heading = "Common Commands")] count: usize, /// Output ready-to-use client sharing links (ostp://...) from the server configuration - #[arg(long)] + #[arg(long, help_heading = "Server Commands")] links: bool, /// Validate configuration file and exit - #[arg(long)] + #[arg(long, help_heading = "Common Commands")] check: bool, /// Optional client connection share link (ostp://ACCESS_KEY@HOST:PORT) to run instantly + #[arg(help_heading = "Client Commands")] url: Option, /// Uninstall OSTP: stop service, remove binary and configuration files - #[arg(long)] + #[arg(long, help_heading = "Common Commands")] uninstall: bool, /// Update OSTP: re-run the install script to fetch and install the latest version - #[arg(long)] + #[arg(long, help_heading = "Common Commands")] update: bool, /// Import a share link (ostp://...) into the configuration file and exit - #[arg(long)] + #[arg(long, help_heading = "Client Commands")] import: Option, /// Output shell export commands for proxy (eval $(ostp --proxy-env)) - #[arg(long)] + #[arg(long, help_heading = "Client Commands")] proxy_env: bool, /// Output shell export commands to clear proxy (eval $(ostp --proxy-env-clear)) - #[arg(long)] + #[arg(long, help_heading = "Client Commands")] proxy_env_clear: bool, /// Force migration of the configuration file to the latest format and exit - #[arg(long)] + #[arg(long, help_heading = "Common Commands")] migrate: bool, + + /// Run the network prober to find the fastest DNS resolver for the DNS Transport + #[arg(long, help_heading = "Client Commands")] + prober: bool, } fn parse_ostp_link(link: &str) -> Result { @@ -90,6 +97,8 @@ fn parse_ostp_link(link: &str) -> Result { let mut tun_enabled = false; let mut _tun_dns = None; let mut _wss_enabled = false; + let mut dns_domain = None; + let mut dns_pubkey = None; for (k, v) in parsed.query_pairs() { match &*k { @@ -98,10 +107,25 @@ fn parse_ostp_link(link: &str) -> Result { "tun" => tun_enabled = v == "true", "dns" => _tun_dns = Some(v.into_owned()), "wss" => _wss_enabled = v == "true", + "domain" => dns_domain = Some(v.into_owned()), + "pubkey" => dns_pubkey = Some(v.into_owned()), _ => {} } } + let mut transport_json = serde_json::json!({ + "type": transport_mode + }); + + if transport_mode == "dns" { + if let Some(d) = dns_domain { + transport_json["domain"] = serde_json::json!(d); + } + if let Some(p) = dns_pubkey { + transport_json["pubkey"] = serde_json::json!(p); + } + } + Ok(serde_json::json!({ "version": "0.3.1", "log": { @@ -129,9 +153,7 @@ fn parse_ostp_link(link: &str) -> Result { "server": parsed.host_str().unwrap_or(""), "port": parsed.port().unwrap_or(50000), "access_key": access_key, - "transport": { - "type": transport_mode - }, + "transport": transport_json, "multiplex": { "enabled": false, "sessions": 1 @@ -776,31 +798,36 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> { wizard_step(3, TOTAL, "Service registration"); // intentional: step text then daemon call below + let port_str = listen.split(':').last().unwrap_or("50000"); + let port: u16 = port_str.parse().unwrap_or(50000); let server_json = serde_json::json!({ "mode": "server", "version": "0.3.1", "log": { "level": "info" }, - "listen": listen, - "access_keys": access_keys, - "outbound": { + "dns_transport": { "enabled": false, - "protocol": "socks5", - "address": "127.0.0.1", - "port": 9050, - "default_action": "proxy", - "rules": [] + "listen": "0.0.0.0:53", + "domain": "tunnel.yourdomain.com", + "pubkey": "", + "privkey": "" }, - "api": { - "enabled": false, - "bind": "0.0.0.0:9090", - "webpath": "", - "username": "", - "password_hash": "" - }, - "fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" }, - "debug": false + "inbounds": [ + { + "type": "ostp", + "tag": "ostp-in", + "listen": "0.0.0.0", + "port": port, + "users": access_keys + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ] }); let actual_path = wizard_save_config(config_path, &server_json)?; @@ -898,6 +925,13 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> { "log": { "level": "info" }, + "dns_transport": { + "enabled": false, + "listen": "0.0.0.0:53", + "domain": "tunnel.yourdomain.com", + "pubkey": "", + "privkey": "" + }, "inbounds": [ { "type": "ostp", @@ -1383,7 +1417,18 @@ async fn run_app() -> Result<()> { "target": "127.0.0.1:8080" }}, - "debug": false + "debug": false, + + // [WARNING] This is a last-resort transport via public DNS. + // It requires a dedicated registered domain with NS records pointing to this server. + // Full setup guide: https://github.com/ospab/ostp/wiki/DNS-Tunneling + "dns_transport": {{ + "enabled": false, + "listen": "0.0.0.0:53", + "domain": "tunnel.example.com", + "pubkey": "SERVER_PUBKEY_BASE64_HERE", + "privkey": "SERVER_PRIVKEY_BASE64_HERE" + }} }}"#, key) } else if mode_str == "relay" { r#"{ @@ -1501,6 +1546,11 @@ async fn run_app() -> Result<()> { return Ok(()); } + if args.prober { + dns_prober::run_prober().await; + return Ok(()); + } + // Validate config file existence if !args.config.exists() { anyhow::bail!( @@ -1685,7 +1735,7 @@ async fn run_app() -> Result<()> { host_port.0.to_string() }; - ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?; + ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, server_cfg.dns_transport, Some(args.config)).await?; } AppMode::Client(client_cfg) => { println!("{}", include_str!("../../docs/banner.txt").blue().bold());