tesseras-paste 0.1.3

Decentralized pastebin built on tesseras-dht
//! 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
}