Commit Diff


commit - 7aff2e1d279a4e442b32f49ca0a0eca065355787
commit + ee25588324ca61275782a3628dd1838dae58e69e
blob - 15e7d9b3bd1972a1843292ae0c441c41cd96c38c
blob + 2b7fdb20b84927b84ab1f95b3ec03e41879a2a33
--- src/bin/tpd.rs
+++ 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}");
         }
blob - /dev/null
blob + 8c2af0f730121f7b346134aaf7e06c1eb2746345 (mode 644)
--- /dev/null
+++ 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
+}
blob - 73a4114be85b4aa70981d5cfd4213fcf235931e7
blob + 5d9c4fec5528ae8d1e0214bb5646e65f0b641db5
--- tpd.1
+++ tpd.1
@@ -22,6 +22,7 @@
 .Sh SYNOPSIS
 .Nm
 .Op Fl g
+.Op Fl n
 .Op Fl p Ar port
 .Op Fl d Ar dir
 .Op Fl s Ar sock
@@ -42,12 +43,23 @@ The options are as follows:
 Bootstrap peer address.
 This option can be specified multiple times to connect to
 several peers at startup.
+When specified, automatic SRV discovery is skipped.
 .It Fl d Ar dir
 Data directory for paste storage and identity key.
 The default is
 .Pa /var/tesseras-paste .
 .It Fl g
 Enable global NAT mode for public servers.
+.It Fl n
+Disable automatic bootstrap via DNS SRV discovery.
+By default, when no
+.Fl b
+peers are given,
+.Nm
+queries the SRV record at
+.Em _tesseras._udp.tesseras.net
+to discover bootstrap peers.
+This flag disables that behavior, useful for running an isolated node.
 .It Fl p Ar port
 UDP port for the DHT protocol.
 A value of 0 selects a random port.
@@ -60,6 +72,15 @@ inside the data directory.
 .It Fl w Ar http_port
 Enable the HTTP server on the specified port.
 .El
+.Sh BOOTSTRAP DISCOVERY
+When no
+.Fl b
+peers are specified and
+.Fl n
+is not set,
+.Nm
+automatically discovers bootstrap peers by querying the DNS SRV record
+.Em _tesseras._udp.tesseras.net .
 .Sh FILES
 .Bl -tag -width "/var/tesseras-paste/identity.key" -compact
 .It Pa /var/tesseras-paste/
@@ -84,6 +105,10 @@ Start with a bootstrap peer and HTTP interface:
 Start as a public server on port 6881:
 .Pp
 .Dl # tpd -g -p 6881
+.Pp
+Start an isolated node without automatic bootstrap:
+.Pp
+.Dl # tpd -n
 .Sh SEE ALSO
 .Xr tp 1
 .Sh AUTHORS