142 / Networking

Check SPF, DKIM and DMARC before sending mail

An application that will email invoices wants to check, by itself, that the sender's domain has its DNS records in order — and to show the administrator what is missing instead of letting messages land in spam. SPF is a TXT record of the domain starting with v=spf1; DKIM is the CNAME (or TXT) of <selector>._domainkey.<domain>; DMARC is a TXT record of _dmarc.<domain> starting with v=DMARC1.

142-dns-spf-dkim-dmarc.nxSource →
// Nyx by Example: verificar SPF, DKIM y DMARC antes de enviar correo (std/dns).
//
// El caso real: una aplicación que va a mandar facturas por correo quiere
// comprobar, desde sí misma, que el dominio del remitente tiene sus registros
// DNS en regla, y mostrarle al administrador qué falta en lugar de dejar que
// los mensajes caigan en spam:
//   - SPF   : un registro TXT del dominio que empieza por "v=spf1";
//   - DKIM  : el CNAME (o TXT) de «<selector>._domainkey.<dominio>»;
//   - DMARC : un registro TXT de «_dmarc.<dominio>» que empieza por "v=DMARC1".
//
// Lo que muestra:
//   - dns_txt devuelve UN elemento por registro (las cadenas de 255 bytes de un
//     registro largo ya vienen unidas), así que basta buscar el prefijo;
//   - distinguir «el nombre no existe» (NXDOMAIN) de «existe pero no publicó ese
//     registro» (NODATA) con dns_is_nxdomain / dns_is_nodata, sin leer mensajes;
//   - separar la LÓGICA de comprobar (funciones puras sobre los registros) de la
//     consulta de red, para probarla sin red;
//   - este módulo da los registros, no el veredicto: evaluar `include:` de SPF o
//     la alineación de DKIM es trabajo de quien lo usa.
//
// Correr:  nyx run                       (sin argumentos: demostración sin red)
//          nyx run -- midominio.com sel1 (consulta el DNS real del sistema)
// Sin `nameserver` en /etc/resolv.conf (o en un sistema sin ese archivo) llamar antes a
// dns_set_server("ip").

import "std/dns"
import "std/error"

// ---- lógica pura: no toca la red

fn tiene_prefijo(s: String, pref: String) -> bool {
    if s.length() < pref.length() { return false }
    return s.substring(0, pref.length()).to_lower() == pref.to_lower()
}

// El primer registro cuyo texto empieza por `pref`, o "" si ninguno.
fn buscar(recs: Array, pref: String) -> String {
    var i: int = 0
    while i < recs.length() {
        let r: String = recs[i]
        if tiene_prefijo(r.trim(), pref) { return r.trim() }
        i = i + 1
    }
    return ""
}

// La política de un registro DMARC: «p=reject; rua=...» -> "reject".
fn dmarc_politica(registro: String) -> String {
    let partes: Array = registro.split(";")
    var i: int = 0
    while i < partes.length() {
        let p: String = partes[i]
        let t: String = p.trim()
        if tiene_prefijo(t, "p=") { return t.substring(2, t.length()).trim() }
        i = i + 1
    }
    return ""
}

// Informe de un dominio a partir de lo que devolvió el DNS (o "" si no hay).
fn informe(spf: String, dkim: String, dmarc: String) -> Array {
    var out: Array = []
    if spf == "" { out.push("SPF: falta el registro TXT 'v=spf1 ...' del dominio") }
    else { out.push("SPF: ok (" + spf + ")") }
    if dkim == "" { out.push("DKIM: falta el registro del selector") }
    else { out.push("DKIM: ok (apunta a " + dkim + ")") }
    if dmarc == "" {
        out.push("DMARC: falta el registro TXT de _dmarc")
    } else {
        let pol: String = dmarc_politica(dmarc)
        if pol == "none" { out.push("DMARC: presente pero p=none (solo monitorea, no protege)") }
        else { out.push("DMARC: ok (p=" + pol + ")") }
    }
    return out
}

// ---- consulta de red: cada pieza distingue «no existe» de «no publicó»

// Los TXT de un nombre, o [] si no hay (NXDOMAIN o NODATA); Err solo por fallos reales.
fn txt_o_vacio(nombre: String) -> Result<Array, Error> {
    let r: Result<Array, Error> = dns_txt(nombre)
    match r {
        Result.Ok(v) => { return Result.Ok(v) }
        Result.Err(e) => {
            if dns_is_nxdomain(e) or dns_is_nodata(e) { return Result.Ok([]) }
            return Result.Err(e)
        }
    }
}

fn consultar(dominio: String, selector: String) -> Result<Array, Error> {
    let spf_recs: Result<Array, Error> = txt_o_vacio(dominio)
    var spf: String = ""
    match spf_recs {
        Result.Ok(v) => { spf = buscar(v, "v=spf1") }
        Result.Err(e) => { return Result.Err(e) }
    }

    // DKIM: normalmente un CNAME hacia el proveedor; algunos publican el TXT directo.
    var dkim: String = ""
    let nombre_dkim: String = selector + "._domainkey." + dominio
    let c: Result<String, Error> = dns_cname(nombre_dkim)
    match c {
        Result.Ok(destino) => { dkim = destino }
        Result.Err(e) => {
            if not (dns_is_nxdomain(e) or dns_is_nodata(e)) { return Result.Err(e) }
            let t: Result<Array, Error> = txt_o_vacio(nombre_dkim)
            match t {
                Result.Ok(v) => {
                    if buscar(v, "v=dkim1") != "" { dkim = "un registro TXT" }
                }
                Result.Err(e2) => { return Result.Err(e2) }
            }
        }
    }

    let dm: Result<Array, Error> = txt_o_vacio("_dmarc." + dominio)
    var dmarc: String = ""
    match dm {
        Result.Ok(v) => { dmarc = buscar(v, "v=dmarc1") }
        Result.Err(e) => { return Result.Err(e) }
    }
    return Result.Ok(informe(spf, dkim, dmarc))
}

fn imprimir(lineas: Array) {
    var i: int = 0
    while i < lineas.length() {
        print(lineas[i])
        i = i + 1
    }
}

fn main() {
    let args: Array = get_args()
    if args.length() > 1 {
        let dominio: String = args[1]
        var selector: String = "default"
        if args.length() > 2 { selector = args[2] }
        match consultar(dominio, selector) {
            Result.Ok(lineas) => { imprimir(lineas) }
            Result.Err(e) => { print("no se pudo consultar el DNS: " + e.msg) }
        }
        return
    }

    // Demostración sin red: lo que devolvería el DNS de un dominio bien configurado
    // y de uno a medias.
    print("== dominio completo")
    imprimir(informe("v=spf1 include:_spf.proveedor.com -all", "sel1.dkim.proveedor.com", "v=DMARC1; p=reject; rua=mailto:dmarc@mine.com"))
    print("== dominio a medias")
    imprimir(informe("v=spf1 -all", "", "v=DMARC1; p=none"))
    print("== sin nada")
    imprimir(informe("", "", ""))
}
Outputstdout
== dominio completo
SPF: ok (v=spf1 include:_spf.proveedor.com -all)
DKIM: ok (apunta a sel1.dkim.proveedor.com)
DMARC: ok (p=reject)
== dominio a medias
SPF: ok (v=spf1 -all)
DKIM: falta el registro del selector
DMARC: presente pero p=none (solo monitorea, no protege)
== sin nada
SPF: falta el registro TXT 'v=spf1 ...' del dominio
DKIM: falta el registro del selector
DMARC: falta el registro TXT de _dmarc

How it works

dns_txt returns ONE element per record (the 255-byte strings of a long record already come joined), so looking for the prefix is enough. dns_is_nxdomain and dns_is_nodata tell the name does not exist from it exists but published no such record, without reading messages. The checks themselves are pure functions over the records, kept apart from the network query, which is why the demo can run three domains — complete, half-configured and empty — without any DNS.

The module gives the records, not the verdict: evaluating an SPF include: or DKIM alignment is the caller's job. With no arguments, nyx run runs the demonstration; nyx run -- midominio.com sel1 queries the system's real DNS. Without a nameserver in /etc/resolv.conf (or on a system without that file), call dns_set_server("ip") first.