aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/tpd.rs26
-rw-r--r--src/dns.rs340
2 files changed, 362 insertions, 4 deletions
diff --git a/src/bin/tpd.rs b/src/bin/tpd.rs
index 15e7d9b..2b7fdb2 100644
--- a/src/bin/tpd.rs
+++ b/src/bin/tpd.rs
@@ -10,6 +10,8 @@ mod base58;
mod crypto;
#[path = "../daemon.rs"]
mod daemon;
+#[path = "../dns.rs"]
+mod dns;
#[path = "../ops.rs"]
mod ops;
#[path = "../paste.rs"]
@@ -35,7 +37,7 @@ fn default_dir() -> PathBuf {
fn usage() {
eprintln!(
"usage: tpd [-p port] [-d dir] [-s sock] \
- [-w http_port] [-g] [-b host:port] [-h]"
+ [-w http_port] [-g] [-n] [-b host:port] [-h]"
);
eprintln!();
eprintln!(" -p port UDP port (0 = random)");
@@ -43,6 +45,7 @@ fn usage() {
eprintln!(" -s sock Unix socket path");
eprintln!(" -w port HTTP server port");
eprintln!(" -g global NAT (public server)");
+ eprintln!(" -n no auto-bootstrap (skip DNS SRV)");
eprintln!(" -b host:port bootstrap peer (repeatable)");
eprintln!(" -h show this help");
}
@@ -62,6 +65,7 @@ fn main() {
let mut sock: Option<PathBuf> = None;
let mut http_port: Option<u16> = None;
let mut global = false;
+ let mut no_auto_bootstrap = false;
let mut bootstrap: Vec<String> = Vec::new();
let args: Vec<String> = std::env::args().collect();
@@ -104,6 +108,7 @@ fn main() {
);
}
"-g" => global = true,
+ "-n" => no_auto_bootstrap = true,
"-b" => {
i += 1;
if let Some(addr) = args.get(i) {
@@ -181,22 +186,35 @@ fn main() {
let id = node.id_hex();
eprintln!("tpd {addr} id={:.8}", id);
+ // If no explicit peers given and auto-bootstrap is enabled,
+ // discover peers via DNS SRV (_tesseras._udp.tesseras.net).
+ if bootstrap.is_empty() && !no_auto_bootstrap {
+ log::info!("bootstrap: resolving SRV records");
+ let srv = dns::lookup_bootstrap();
+ if srv.is_empty() {
+ log::warn!("bootstrap: no SRV records found");
+ }
+ for rec in &srv {
+ bootstrap.push(format!("{}:{}", rec.host, rec.port));
+ }
+ }
+
for peer in &bootstrap {
let parts: Vec<&str> = peer.rsplitn(2, ':').collect();
if parts.len() != 2 {
- eprintln!("warning: bad bootstrap: {peer}");
+ eprintln!("warning: bad bootstrap address: {peer}");
continue;
}
let host = parts[1];
let p: u16 = match parts[0].parse() {
Ok(p) => p,
Err(_) => {
- eprintln!("warning: bad port: {peer}");
+ eprintln!("warning: bad bootstrap port: {peer}");
continue;
}
};
if let Err(e) = node.join(host, p) {
- eprintln!("warning: bootstrap {peer}: {e}");
+ log::warn!("bootstrap: failed to join {peer}: {e}");
} else {
log::info!("bootstrap: connected to {peer}");
}
diff --git a/src/dns.rs b/src/dns.rs
new file mode 100644
index 0000000..8c2af0f
--- /dev/null
+++ b/src/dns.rs
@@ -0,0 +1,340 @@
+//! DNS SRV record lookup via libc `res_query`.
+//!
+//! Discovers bootstrap peers by querying
+//! `_tesseras._udp.tesseras.net` for SRV records.
+//! Falls back to an empty list on any DNS or parse error,
+//! logging a warning so the operator knows discovery failed.
+//!
+//! ## Anti-spoofing
+//!
+//! Two mitigations against DNS spoofing are applied:
+//!
+//! - **DNSSEC (AD flag)**: if the resolver validated the response
+//! via DNSSEC, the AD bit is set. When AD is absent, a warning
+//! is logged so the operator knows the response is unauthenticated.
+//!
+//! - **Host suffix pinning**: only SRV targets ending in
+//! `.tesseras.net` are accepted. This limits spoofed responses
+//! to hosts within the trusted domain.
+//!
+//! These checks only apply to the automatic SRV discovery path.
+//! Explicit `-b` peers bypass DNS entirely.
+
+/// Default SRV record name for bootstrap discovery.
+const SRV_NAME: &str = "_tesseras._udp.tesseras.net";
+
+/// DNS class: Internet.
+const C_IN: i32 = 1;
+
+/// DNS record type: SRV (RFC 2782).
+const T_SRV: i32 = 33;
+
+/// Fixed DNS header size (ID + flags + 4 counts).
+const HFIXEDSZ: usize = 12;
+
+/// Maximum DNS response buffer. Covers UDP responses
+/// (512 bytes standard, up to 4096 with EDNS0).
+const MAX_ANSWER: usize = 4096;
+
+/// Sanity cap on qdcount/ancount to avoid looping on
+/// malformed responses (no legitimate response has >64 RRs
+/// in 4096 bytes).
+const MAX_RR_COUNT: usize = 64;
+
+/// Only SRV targets under this domain suffix are accepted.
+/// Prevents a spoofed DNS response from directing the daemon
+/// to attacker-controlled hosts.
+const TRUSTED_SUFFIX: &str = ".tesseras.net";
+
+/// Bit mask for the AD (Authenticated Data) flag in the DNS
+/// header flags field (byte 3, bit 5). Set by a validating
+/// resolver when DNSSEC verification succeeded.
+const DNS_FLAG_AD: u8 = 0x20;
+
+/// A resolved SRV record with target host and port.
+pub struct SrvRecord {
+ pub host: String,
+ pub port: u16,
+}
+
+/// `h_errno` values from `<netdb.h>`.
+const HOST_NOT_FOUND: i32 = 1;
+const TRY_AGAIN: i32 = 2;
+const NO_RECOVERY: i32 = 3;
+const NO_DATA: i32 = 4;
+
+unsafe extern "C" {
+ fn res_query(
+ dname: *const u8,
+ class: i32,
+ rtype: i32,
+ answer: *mut u8,
+ anslen: i32,
+ ) -> i32;
+
+ fn dn_expand(
+ msg: *const u8,
+ eomorig: *const u8,
+ comp_dn: *const u8,
+ exp_dn: *mut u8,
+ length: i32,
+ ) -> i32;
+
+ /// Per-thread DNS error code, set by `res_query` on failure.
+ static h_errno: i32;
+}
+
+/// Query DNS for SRV records at `_tesseras._udp.tesseras.net`
+/// and return the discovered (host, port) pairs.
+/// Returns an empty Vec on any DNS or parsing failure.
+pub fn lookup_bootstrap() -> Vec<SrvRecord> {
+ lookup_srv(SRV_NAME)
+}
+
+/// Perform the SRV query and parse the response.
+fn lookup_srv(name: &str) -> Vec<SrvRecord> {
+ let mut buf = vec![0u8; MAX_ANSWER];
+
+ // res_query expects a null-terminated C string.
+ let mut cname = name.as_bytes().to_vec();
+ cname.push(0);
+
+ // SAFETY: cname is a valid null-terminated byte string,
+ // buf is a properly sized mutable buffer.
+ let len = unsafe {
+ res_query(
+ cname.as_ptr(),
+ C_IN,
+ T_SRV,
+ buf.as_mut_ptr(),
+ buf.len() as i32,
+ )
+ };
+
+ if len < 0 {
+ let reason = match unsafe { h_errno } {
+ HOST_NOT_FOUND => "host not found",
+ TRY_AGAIN => "timeout or temporary failure",
+ NO_RECOVERY => "non-recoverable server error",
+ NO_DATA => "no SRV records for this name",
+ other => {
+ log::warn!(
+ "dns: SRV query for {name} failed (h_errno={other})"
+ );
+ return Vec::new();
+ }
+ };
+ log::warn!("dns: SRV query for {name} failed: {reason}");
+ return Vec::new();
+ }
+
+ let len = len as usize;
+
+ if len < HFIXEDSZ {
+ log::warn!("dns: SRV response too short ({len} bytes)");
+ return Vec::new();
+ }
+
+ // res_query returns the full (untruncated) response length,
+ // which may exceed the buffer when the answer was truncated.
+ // Cap to the actual buffer size to avoid an out-of-bounds slice.
+ let len = len.min(buf.len());
+
+ parse_srv_response(&buf[..len])
+}
+
+/// Read a big-endian u16 from `data[*pos..]`, advancing `*pos` by 2.
+/// Returns `None` if there aren't enough bytes remaining.
+fn read_u16(data: &[u8], pos: &mut usize) -> Option<u16> {
+ if *pos + 2 > data.len() {
+ return None;
+ }
+ let val = u16::from_be_bytes([data[*pos], data[*pos + 1]]);
+ *pos += 2;
+ Some(val)
+}
+
+/// Skip over a compressed domain name in the DNS wire format.
+/// Returns `false` if the name is malformed or extends past the buffer.
+fn skip_name(data: &[u8], pos: &mut usize) -> bool {
+ while *pos < data.len() {
+ let label_len = data[*pos] as usize;
+ if label_len == 0 {
+ *pos += 1;
+ return true;
+ }
+ // Compression pointer: top 2 bits set, followed by 1 offset byte.
+ if label_len & 0xC0 == 0xC0 {
+ if *pos + 2 > data.len() {
+ return false;
+ }
+ *pos += 2;
+ return true;
+ }
+ if *pos + 1 + label_len > data.len() {
+ return false;
+ }
+ *pos += 1 + label_len;
+ }
+ false
+}
+
+/// Expand a compressed domain name at `msg[*pos..]` using
+/// libc `dn_expand`. Advances `*pos` past the compressed
+/// name. Returns `None` on any error.
+fn expand_name(msg: &[u8], pos: &mut usize) -> Option<String> {
+ if *pos >= msg.len() {
+ return None;
+ }
+
+ // MAXDNAME is 1025 on OpenBSD; 512 is more than enough
+ // for any valid domain name (max 253 chars + null) and
+ // gives headroom for malformed names without truncation.
+ let mut name_buf = [0u8; 512];
+
+ // SAFETY: pos is bounds-checked above, eomorig points to
+ // one past the last byte of msg. dn_expand will not read
+ // past eomorig and writes at most `length` bytes to exp_dn.
+ let n = unsafe {
+ dn_expand(
+ msg.as_ptr(),
+ msg.as_ptr().add(msg.len()),
+ msg.as_ptr().add(*pos),
+ name_buf.as_mut_ptr(),
+ name_buf.len() as i32,
+ )
+ };
+ if n < 0 {
+ return None;
+ }
+ *pos += n as usize;
+
+ // dn_expand null-terminates the output.
+ let end = name_buf.iter().position(|&b| b == 0).unwrap_or(0);
+ String::from_utf8(name_buf[..end].to_vec()).ok()
+}
+
+/// Parse a raw DNS response and extract SRV records.
+/// Checks the AD flag for DNSSEC validation and rejects
+/// SRV targets outside the trusted domain suffix.
+fn parse_srv_response(data: &[u8]) -> Vec<SrvRecord> {
+ if data.len() < HFIXEDSZ {
+ return Vec::new();
+ }
+
+ // Check DNSSEC AD (Authenticated Data) flag.
+ // Byte 3 of the header contains AD at bit 5.
+ if data[3] & DNS_FLAG_AD != 0 {
+ log::info!("dns: response has AD flag (DNSSEC validated)");
+ } else {
+ log::warn!(
+ "dns: response lacks AD flag (DNSSEC not validated); \
+ results may be spoofed"
+ );
+ }
+
+ let mut pos = 4; // skip ID + flags
+ let qdcount =
+ (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT);
+ let ancount =
+ (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT);
+ pos += 4; // skip nscount + arcount
+
+ // Skip question section.
+ for _ in 0..qdcount {
+ if !skip_name(data, &mut pos) {
+ log::debug!("dns: failed to skip question name");
+ return Vec::new();
+ }
+ if pos + 4 > data.len() {
+ return Vec::new();
+ }
+ pos += 4; // qtype + qclass
+ }
+
+ let mut records = Vec::new();
+
+ for _ in 0..ancount {
+ if !skip_name(data, &mut pos) {
+ break;
+ }
+
+ let rtype = match read_u16(data, &mut pos) {
+ Some(v) => v,
+ None => break,
+ };
+ // rclass
+ if read_u16(data, &mut pos).is_none() {
+ break;
+ }
+ // ttl (4 bytes)
+ if pos + 4 > data.len() {
+ break;
+ }
+ pos += 4;
+
+ let rdlength = match read_u16(data, &mut pos) {
+ Some(v) => v as usize,
+ None => break,
+ };
+
+ // Guard: rdlength must not extend past the buffer.
+ if pos + rdlength > data.len() {
+ log::debug!("dns: rdlength extends past response buffer");
+ break;
+ }
+
+ if rtype != T_SRV as u16 || rdlength < 6 {
+ pos += rdlength;
+ continue;
+ }
+
+ let rdata_start = pos;
+
+ // SRV RDATA: priority(2) + weight(2) + port(2) + target
+ if read_u16(data, &mut pos).is_none() {
+ break;
+ }
+ if read_u16(data, &mut pos).is_none() {
+ break;
+ }
+ let srv_port = match read_u16(data, &mut pos) {
+ Some(v) => v,
+ None => break,
+ };
+
+ let target = match expand_name(data, &mut pos) {
+ Some(name) => name,
+ None => {
+ pos = rdata_start + rdlength;
+ continue;
+ }
+ };
+
+ // Advance to end of RDATA regardless of how much
+ // expand_name consumed (defensive against short reads).
+ pos = rdata_start + rdlength;
+
+ // SRV target "." means no service available (RFC 2782).
+ if target.is_empty() || target == "." {
+ continue;
+ }
+
+ // Anti-spoofing: only accept targets under the trusted domain.
+ let lower = target.to_ascii_lowercase();
+ if !lower.ends_with(TRUSTED_SUFFIX) {
+ log::warn!(
+ "dns: rejecting SRV target '{target}' \
+ (not under {TRUSTED_SUFFIX})"
+ );
+ continue;
+ }
+
+ records.push(SrvRecord {
+ host: target,
+ port: srv_port,
+ });
+ }
+
+ records
+}