feat: integrate DNS Transport (DNS Proxy) as last resort transport

- Implement DnsTransportClient and polling logic
- Implement DnsTransportServer for TXT/NULL record handling
- Add dns_prober to find best public resolvers by region
- Update React GUI (Desktop) to support DNS Proxy and i18n
- Update Flutter App to support DNS Proxy settings
- Update CLI Setup Wizard to generate new v0.3.1 config with dns_transport block
- Add Wiki documentation for DNS Transport
This commit is contained in:
ospab 2026-06-19 01:44:08 +03:00
parent a955946fdb
commit 3f1adbc58f
22 changed files with 1210 additions and 187 deletions

1
Cargo.lock generated
View File

@ -1491,6 +1491,7 @@ name = "ostp-core"
version = "0.3.6"
dependencies = [
"anyhow",
"byteorder",
"bytes",
"chacha20poly1305",
"hkdf",

1
dnstt Submodule

@ -0,0 +1 @@
Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2

13
icons/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
}
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,
}
}
}

View File

@ -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<String>) -> std::io::Result<Transport> {
let (app_tx, transport_rx) = mpsc::channel::<Bytes>(100);
let (transport_tx, app_rx) = mpsc::channel::<Bytes>(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)),
})
}

View File

@ -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<Bytes>,
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
},
Dns {
tx: tokio::sync::mpsc::Sender<Bytes>,
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
}
}
@ -16,8 +20,8 @@ impl Transport {
pub async fn send(&self, frame: &Bytes) -> std::io::Result<usize> {
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<usize> {
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<usize> {
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<std::net::SocketAddr> {
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()),
}
}
}

View File

@ -10,7 +10,7 @@ pub async fn dial_tcp(
server: &str,
port: u16,
access_key: &str,
_transport: &TransportConfig,
transport_cfg: &TransportConfig,
_multiplex: &MultiplexConfig,
) -> Result<TcpStream> {
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; }
_ => {}
}

View File

@ -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"] }

413
ostp-core/src/dns.rs Normal file
View File

@ -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<Vec<u8>> {
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<u16> 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<u8>,
}
#[derive(Debug, Clone)]
pub struct DnsPacket {
pub id: u16,
pub flags: u16,
pub questions: Vec<DnsQuestion>,
pub answers: Vec<DnsAnswer>,
}
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<u8>) -> 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<u8> {
let mut buf = Vec::new();
let _ = buf.write_u16::<BigEndian>(self.id);
let _ = buf.write_u16::<BigEndian>(self.flags);
let _ = buf.write_u16::<BigEndian>(self.questions.len() as u16);
let _ = buf.write_u16::<BigEndian>(self.answers.len() as u16);
let _ = buf.write_u16::<BigEndian>(0); // Authority PR
let _ = buf.write_u16::<BigEndian>(0); // Additional PR
for q in &self.questions {
encode_domain_name(&mut buf, &q.name);
let _ = buf.write_u16::<BigEndian>(q.qtype.as_u16());
let _ = buf.write_u16::<BigEndian>(q.qclass);
}
for a in &self.answers {
encode_domain_name(&mut buf, &a.name);
let _ = buf.write_u16::<BigEndian>(a.rtype.as_u16());
let _ = buf.write_u16::<BigEndian>(a.rclass);
let _ = buf.write_u32::<BigEndian>(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::<BigEndian>(txt_data.len() as u16);
buf.extend_from_slice(&txt_data);
} else {
let _ = buf.write_u16::<BigEndian>(a.rdata.len() as u16);
buf.extend_from_slice(&a.rdata);
}
}
buf
}
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < 12 {
return None;
}
let mut cursor = Cursor::new(data);
let id = cursor.read_u16::<BigEndian>().ok()?;
let flags = cursor.read_u16::<BigEndian>().ok()?;
let qdcount = cursor.read_u16::<BigEndian>().ok()?;
let ancount = cursor.read_u16::<BigEndian>().ok()?;
let _nscount = cursor.read_u16::<BigEndian>().ok()?;
let _arcount = cursor.read_u16::<BigEndian>().ok()?;
let mut questions = Vec::new();
for _ in 0..qdcount {
let name = decode_domain_name(&mut cursor, data)?;
let qtype = cursor.read_u16::<BigEndian>().ok()?.into();
let qclass = cursor.read_u16::<BigEndian>().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::<BigEndian>().ok()?.into();
let rclass = cursor.read_u16::<BigEndian>().ok()?;
let ttl = cursor.read_u32::<BigEndian>().ok()?;
let rdlength = cursor.read_u16::<BigEndian>().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<u8>, 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<String> {
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<Vec<u8>> {
// 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);
}
}

View File

@ -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};

View File

@ -32,13 +32,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_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<SettingsScreen> {
_domainsCtrl.dispose();
_ipsCtrl.dispose();
_processesCtrl.dispose();
_stealthSniCtrl.dispose();
_dnsDomainCtrl.dispose();
_pbkCtrl.dispose();
_sidCtrl.dispose();
_muxSessionsCtrl.dispose();
@ -97,10 +97,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
RadioListTile<String>(
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<SettingsScreen> {
RadioListTile<String>(
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<String>(
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<String> 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<String>(
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<String>(
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<String>(
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<String>(
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<SettingsScreen> {
if (host.isEmpty || key.isEmpty) return '';
final queryParams = <String>[];
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<SettingsScreen> {
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');
}

View File

@ -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)',

View File

@ -196,27 +196,31 @@
<div class="field-group">
<label class="field-label" for="in-transport" data-i18n="label_transport">Transport Protocol</label>
<select id="in-transport" class="field-input">
<option value="udp">UDP (Default)</option>
<option value="uot">TCP (UoT)</option>
<option value="udp" data-i18n="opt_udp">UDP (Default)</option>
<option value="uot" data-i18n="opt_uot">TCP (UoT)</option>
<option value="dns" data-i18n="opt_dns">DNS Proxy (Last Resort)</option>
</select>
</div>
<div class="field-group">
<label class="field-label" for="in-stealth-sni" data-i18n="label_sni">Stealth SNI</label>
<input id="in-stealth-sni" class="field-input" type="text" placeholder="www.microsoft.com" spellcheck="false" />
</div>
<div class="toggle-row" id="group-wss">
<div class="toggle-text">
<span class="toggle-name">WebSocket (WSS)</span>
<span class="toggle-hint">Use RFC 6455 framing for strict DPI bypass</span>
<div id="group-dns-proxy" style="display: none;">
<div class="field-group">
<label class="field-label" for="in-dns-domain" style="color: var(--c-warning);" data-i18n="label_dns_domain">Domain (Points to Server)</label>
<input id="in-dns-domain" class="field-input" type="text" placeholder="tunnel.myvpn.com" spellcheck="false" />
<div style="font-size: 0.8rem; color: var(--c-sub); margin-top: 4px;">
<span data-i18n="dns_domain_hint">This is the "last resort" over public DNS servers. You need a domain pointing to your server (NS/A record).</span>
<span data-i18n="dns_guide">Detailed setup guide available in</span> <a href="https://github.com/ospab/ostp/wiki/DNS-Transport" target="_blank" style="color: var(--c-accent)" data-i18n="wiki_link">GitHub Wiki</a>.
</div>
</div>
<div class="field-group">
<label class="field-label" for="in-dns-region" data-i18n="label_dns_region">DNS Resolver Region (Prober)</label>
<select id="in-dns-region" class="field-input">
<option value="Global" data-i18n="opt_global">Global (Cloudflare, Google, etc)</option>
<option value="Russia" data-i18n="opt_russia">Russia (Yandex, VK, etc)</option>
<option value="China" data-i18n="opt_china">China (AliDNS, DNSPod, etc)</option>
<option value="Iran" data-i18n="opt_iran">Iran (Shatel, Electro, etc)</option>
</select>
</div>
<label class="toggle">
<input type="checkbox" id="in-wss" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="field-group">

View File

@ -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';

View File

@ -121,4 +121,17 @@ pub struct ModularServerConfig {
pub dns: Option<DnsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_transport: Option<DnsTransportConfig>,
}
#[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,
}

View File

@ -71,6 +71,7 @@ pub async fn run_server(
fallback_config: Option<FallbackConfig>,
debug: bool,
dns_config: Option<dns::DnsConfig>,
dns_transport: Option<crate::config::DnsTransportConfig>,
config_path: Option<std::path::PathBuf>,
) -> 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<UiEvent>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
router: std::sync::Arc<crate::router::Router>,
dns_transport: Option<crate::config::DnsTransportConfig>,
) -> Result<()> {
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
@ -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 {

View File

@ -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<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) {
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::<Bytes>(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);
}
}
}
}

View File

@ -1 +1,2 @@
pub mod uot;
pub mod dns;

View File

@ -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-фильтров.

View File

@ -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)

149
ostp/src/dns_prober.rs Normal file
View File

@ -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.");
}

View File

@ -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<String>,
/// 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<String>,
/// 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<String>,
/// 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<serde_json::Value> {
@ -90,6 +97,8 @@ fn parse_ostp_link(link: &str) -> Result<serde_json::Value> {
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<serde_json::Value> {
"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<serde_json::Value> {
"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());