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
+
+
+
+
+
+ This is the "last resort" over public DNS servers. You need a domain pointing to your server (NS/A record).
+ Detailed setup guide available inGitHub Wiki.
+
+
+
+
+
+
-
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());