diff --git a/examples/nginx-doh-and-dot-to-dns-inject.conf b/examples/nginx-doh-and-dot-to-dns-inject.conf new file mode 100644 index 0000000..86adf74 --- /dev/null +++ b/examples/nginx-doh-and-dot-to-dns-inject.conf @@ -0,0 +1,131 @@ +# inject CSUBNET into dns queries (useful to pass client identification i.e. while DoT reverse-proxying pihole) + +user www-data; +worker_processes 1; +#worker_processes auto; + +pid /run/nginx.pid; +error_log /var/log/nginx/dns-error.log notice; + +include /etc/nginx/modules-enabled/*.conf; + + +events { + worker_connections 1024; +} + +stream { + + # DNS logging + log_format dns '$remote_addr [$time_local] $protocol $status $bytes_sent $bytes_received "$dns_qname" "$dns_client"'; + + access_log /var/log/nginx/dns-access.log dns; + + # Import the NJS module + js_import /etc/nginx/njs.d/dns/dns.js; + + # The $dns_qname variable can be populated by preread calls, and can be used for DNS routing + js_set $dns_qname dns.get_qname; + + # The $dns_client variable can be populated by preread calls, and can be used for DNS routing + js_set $dns_client dns.get_client; + + +### DNS over TLS reverse proxy ### + + # DoT endpoint + server { + # listen to DNS over TLS + listen 853 ssl; + + # SSL keys + ssl_certificate /etc/letsencrypt/live/pihole/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/pihole/privkey.pem; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + # SSL settings + #ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers on; + + # listen to DNS over TCP + #listen 53; + # listen to DNS over UDP + #listen 53 udp; + # treat single udp packets (enable for UDP) + #proxy_responses 1; + + # handle DoT request + js_preread dns.preread_dns_request; + + # Upstream can be either DNS(TCP) or DoT. + proxy_pass 127.0.0.1:53; + # If upstream is DNS, proxy_ssl should be off. + #proxy_ssl on; + } + + +### DNS over HTTPS reverse proxy ### + + server { + # dohloop + listen unix:/tmp/dohloop.socket; + #listen 127.0.0.1:44353; + # handle DoH request + js_filter dns.filter_doh_request; + + # Upstream can be either DNS(TCP) or DoT. + proxy_pass 127.0.0.1:53; + # If upstream is DNS, proxy_ssl should be off. + #proxy_ssl on; + } + +} + +http { + # DoH endpoint + server { + # listen to DNS over HTTPS + listen 443 ssl http2; + # listen to DNS over plain HTTP + #listen 80 http2; + # DoH requires HTTP/2 + #http2 on; # nginx>1.25 + + # SSL keys + ssl_certificate /etc/letsencrypt/live/pihole/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/pihole/privkey.pem; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + + # SSL settings + #ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers on; + + # Return 404 to all responses, except for those using our published DoH URI + location / { + return 404 "404 Not Found\n"; + } + + location /dns-query { + # log settings off in favor of stream access log on stream + access_log off; + + # Proxy HTTP/1.1 + proxy_http_version 1.1; + # clear the connection header to enable Keep-Alive + proxy_set_header Connection ""; + # pass client address + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy to dohloop + proxy_pass http://unix:/tmp/dohloop.socket; + #proxy_pass http://127.0.0.1:44353; + } + + location /version { + js_import /etc/nginx/njs.d/utils.js; + js_content utils.version; + } + } +} + diff --git a/njs.d/dns/dns.js b/njs.d/dns/dns.js index 1367987..e55beb9 100644 --- a/njs.d/dns/dns.js +++ b/njs.d/dns/dns.js @@ -1,5 +1,5 @@ import dns from "libdns.js"; -export default {get_qname, get_response, preread_doh_request, preread_dns_request, filter_doh_request}; +export default {get_qname, get_client, get_response, preread_doh_request, preread_dns_request, filter_doh_request}; /** * DNS Decode Level @@ -17,6 +17,14 @@ var dns_decode_level = 3; **/ var dns_debug_level = 3; +/** + * DNS Inject Client + * Set this to true if you want to inject the client address into the DNS query. + * Doing so will inject the client IP address as CSUBNET in EDNS(0). + * With this you can track clients on your DNS server and selectively blocklist. + * Some DNS servers like pihole feature client specific blocklists out of the box. +**/ +var dns_inject_client = false; /** * DNS Question Load Balancing * Set this to true, if you want to pick the upstream pool based on the DNS Question. @@ -27,10 +35,18 @@ var dns_question_balancing = false; // The DNS Question name var dns_name = Buffer.alloc(0); +// The DNS client address +var dns_client_ip; + + function get_qname(s) { return dns_name; } +function get_client(s) { + return dns_client_ip; +} + // The Optional DNS response, this is set when we want to block a specific domain var dns_response = Buffer.alloc(0); @@ -54,15 +70,12 @@ function process_doh_request(s, decode, scrub) { if ( data.length == 0 ) { return; } - var dataString = data.toString('utf8'); - const lines = dataString.split("\r\n"); var bytes; var packet; - if(lines[0].startsWith("GET")) { - var line = lines[0]; - var path = line.split(" ")[1] - var params = path.split("?")[1] - var qs = params.split("&"); + if(data.toString('utf8', 0, 3) == "GET") { + const path = data.toString('utf8', 4, data.indexOf(' ', 4)); + const params = path.split("?")[1] + const qs = params.split("&"); qs.some( param => { if (param.startsWith("dns=") ) { bytes = Buffer.from(param.slice(4), "base64url"); @@ -72,15 +85,8 @@ function process_doh_request(s, decode, scrub) { }); } - if(lines[0].startsWith("POST")) { - const index = lines.findIndex(line=>{ - if(line.length == 0) { - return true; - } - }) - if(index>0 && lines.length >= index + 1){ - bytes = Buffer.from(lines[index + 1]); - } + if(data.toString('utf8', 0, 4) == "POST") { + bytes = data.slice(data.indexOf('\r\n\r\n') + 4); } if (bytes) { @@ -92,6 +98,15 @@ function process_doh_request(s, decode, scrub) { debug(s,"process_doh_request: DNS Req Name: " + packet.question.name); dns_name = packet.question.name; } + if (dns_inject_client) { + packet = packet ?? dns.parse_packet(bytes); + const index_xforwardedfor = data.indexOf('\r\nX-Forwarded-For: ') + 19; + dns_client_ip = data.toString( + 'utf8', index_xforwardedfor, data.indexOf('\r\n', index_xforwardedfor) + ).split(',')[0]; + debug(s, "process_doh_request: DNS Client IP: \"" + dns_client_ip + "\""); + bytes = inject_client(s, bytes, packet, dns_client_ip); + } if (scrub) { domain_scrub(s, bytes, packet); s.done(); @@ -128,6 +143,12 @@ function process_dns_request(s, decode, scrub) { debug(s,"process_dns_request: DNS Req Name: " + packet.question.name); dns_name = packet.question.name; } + if (dns_inject_client) { + packet = packet ?? dns.parse_packet(bytes); + dns_client_ip = s.variables.remote_addr; + debug(s, "process_dns_request: DNS Client IP: \"" + dns_client_ip + "\""); + bytes = inject_client(s, bytes, packet, dns_client_ip); + } if (scrub) { domain_scrub(s, bytes, packet); s.done(); @@ -269,3 +290,33 @@ function filter_doh_request(s) { }); } +/** + * Inject client address as CSUBNET to DNS query +**/ +function inject_client(s, data, packet, client_ip) { + + dns.parse_complete(packet, 2); + + packet.edns = packet.edns ?? { opts: {} }; + + const has_csubnet = 'csubnet' in packet.edns.opts; + if ( has_csubnet ) { + dns_client_ip = packet.edns.opts.csubnet.subnet + " via " + dns_client_ip; + debug(s, "inject client: query already has CSUBNET: " + packet.edns.opts.csubnet.subnet + "/" + packet.edns.opts.csubnet.netmask); + } else { + const is_ipv4 = dns.is_ipv4(client_ip); + debug(s, "inject_client: injecting client address: " + client_ip); + packet.edns.opts.csubnet = { + family: (is_ipv4 ? "1" : "2"), + netmask: (is_ipv4 ? "32" : "128"), + scope: 0, + subnet: client_ip + }; + data = dns.encode_packet(packet); + debug(s, "inject_client: DNS Request Packet: " + JSON.stringify( Object.entries(packet)) ); + debug(s, "inject_client: DNS Req: " + data.toString('hex') ); + } + + return data; +} + diff --git a/njs.d/dns/libdns.js b/njs.d/dns/libdns.js index df4ffe4..649070e 100644 --- a/njs.d/dns/libdns.js +++ b/njs.d/dns/libdns.js @@ -5,13 +5,14 @@ **/ export default {dns_type, dns_class, dns_flags, dns_codes, - parse_packet, parse_question, parse_answers, + edns_opcode, is_ipv4, + parse_packet, parse_question, parse_answers, parse_complete, parse_resource_record, shortcut_response, shortcut_nxdomain, gen_new_packet, gen_response_packet, encode_packet} // DNS Types -var dns_type = Object.freeze({ +const dns_type = Object.freeze({ A: 1, NS: 2, CNAME: 5, @@ -30,7 +31,7 @@ var dns_type = Object.freeze({ }); // DNS Classes -var dns_class = Object.freeze({ +const dns_class = Object.freeze({ IN: 1, CS: 2, CH: 3, @@ -39,7 +40,7 @@ var dns_class = Object.freeze({ }); // DNS flags (made up of QR, Opcode (4bits), AA, TrunCation, Recursion Desired) -var dns_flags = Object.freeze({ +const dns_flags = Object.freeze({ QR: 0x80, AA: 0x4, TC: 0x2, @@ -47,7 +48,7 @@ var dns_flags = Object.freeze({ }); // DNS Codes (made up of RA (Recursion Available), Zero (3bits), Response Code (4bits)) -var dns_codes = Object.freeze({ +const dns_codes = Object.freeze({ RA: 0x80, Z: 0x70, //RCODE: 0xf, @@ -60,6 +61,29 @@ var dns_codes = Object.freeze({ value: { 0x80:"RA", 0x70:"Z", 0x0:"NOERROR", 0x1:"FORMERR", 0x2:"SERVFAIL", 0x3:"NXDOMAIN", 0x4:"NOTIMPL", 0x5:"REFUSED" } }); +// EDNS opcodes (see https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-11) +const edns_opcode = Object.freeze({ + LLQ: 1, + UL: 2, + NSID: 3, + DAU: 5, + DHU: 6, + N3U: 7, + CSUBNET: 8, + EXPIRE: 9, + COOKIE: 10, + KEEPALIVE: 11, + PADDING: 12, + CHAIN: 13, + KEYTAG: 14, + ERROR: 15, + CLIENTTAG: 16, + SERVERTAG: 17, + UMBRELLA: 20292, + DEVICEID: 26946, + value: { 1:"LLQ", 2:"UL", 3:"NSID", 5:"DAU", 6:"DHU", 7:"N3U", 8:"CSUBNET", 9:"EXPIRE", 10:"COOKIE", 11:"KEEPALIVE", 12:"PADDING", 13:"CHAIN", 14:"KEYTAG", 15:"ERROR", 16:"CLIENTTAG", 17:"SERVERTAG", 20292:"UMBRELLA", 26946:"DEVICEID" } +}); + // Encode the given number to two bytes (16 bit) function to_bytes( number ) { return Buffer.from( [ ((number>>8) & 0xff), (number & 0xff) ] ); @@ -123,6 +147,7 @@ function encode_packet( packet ) { packet.additional.forEach( function(adtnl) { encoded = Buffer.concat( [ encoded, gen_resource_record(packet, adtnl.name, adtnl.type, adtnl.class, adtnl.ttl, adtnl.rdata)] ); }); + encoded = Buffer.concat( [ encoded, gen_edns_options(packet) ] ); // EDNS options (a special additional record of dns_type.OPT) return encoded; } @@ -327,17 +352,21 @@ function gen_resource_record(packet, name, type, clss, ttl, rdata) { RDATA variable length string **/ - var resource + var resource; var record = ""; - if ( name == packet.question.name ) { + if ( type == dns_type.OPT ) { + // skip EDNS here - it should be triggered already in the calling function + return Buffer.alloc(0); + } + else if ( name == packet.question.name ) { // The name matches the query, set a compression pointer. resource = Buffer.from([192, 12]); } else { // gen labels for the name resource = encode_label(name); } - + resource = Buffer.concat( [ resource, Buffer.from([ type & 0xff00, type & 0xff ]) ]); switch(type) { case dns_type.A: @@ -416,10 +445,16 @@ function parse_resource_record(packet, decode_level) { } else { switch(resource.type) { case dns_type.A: - resource.rdata = parse_arpa_v4(packet, resource); + resource.rdata = parse_arpa_v4( + packet.data.slice(packet.offset, packet.offset + resource.rdlength) + ); + packet.offset += resource.rdlength; break; case dns_type.AAAA: - resource.rdata = parse_arpa_v6(packet, resource); + resource.rdata = parse_arpa_v6( + packet.data.slice(packet.offset, packet.offset + resource.rdlength) + ); + packet.offset += resource.rdlength; break; case dns_type.NS: resource.rdata = parse_label(packet); @@ -449,6 +484,13 @@ function parse_resource_record(packet, decode_level) { return resource; } +function is_ipv4(ip) { + // Determine if IP is IPv4 (true) or IPv6 (false) + const segments = ip.split('\.'); + return (segments.length == 4 && + segments.every(s => (s >= 0 && s <= 255))); +} + function encode_arpa_v4( ipv4 ) { var rdata = Buffer.alloc(4); var index = 0; @@ -458,29 +500,46 @@ function encode_arpa_v4( ipv4 ) { return rdata; } -function parse_arpa_v4(packet) { +function parse_arpa_v4(rdata) { var octet = [0,0,0,0]; - for (var i=0; i< 4 ; i++ ) { - octet[i] = packet.data[packet.offset++]; + for (var i=0; i < rdata.length; i++ ) { + octet[i] = rdata[i]; } return octet.join("."); } function encode_arpa_v6( ipv6 ) { var rdata = Buffer.alloc(0); + // expand :: to up to 7 : separators + const n = 7 - ipv6.match(/:/g).length; + ipv6 = ipv6.replace('::', ':'.repeat(n + 2)); ipv6.split(':').forEach( function(segment) { + while ( segment.length < 4 ) { + segment = "0" + segment; + } rdata = Buffer.concat( [ rdata, Buffer.from( segment[0] + segment[1], 'hex') ] ); rdata = Buffer.concat( [ rdata, Buffer.from( segment[2] + segment[3], 'hex') ] ); }); return rdata; } -function parse_arpa_v6(packet) { +function parse_arpa_v6(rdata) { var ipv6 = ""; - for (var i=0; i<8; i++ ) { - ipv6 += packet.data.toString('hex', packet.offset++, ++packet.offset) + ":"; + var segment; + for ( var i = 0; i < rdata.length; i += 2 ) { + segment = rdata.toString('hex', i, i + 2); + if ( segment.length <= 2 ) { + // padding for sparse subnet (i.e. /120) + segment += "00"; + } + ipv6 += segment + ":"; + } + if ( rdata.length <= 112 ) { + ipv6 += ":"; + } else { + ipv6 = ipv6.slice(0, -1); } - return ipv6.slice(0,-1); + return ipv6; } function encode_txt_record( text_array ) { @@ -533,7 +592,6 @@ function encode_srv_record( srv ) { rdata.writeInt16BE( srv.weight, 2 ); rdata.writeInt16BE( srv.port, 4 ); rdata = Buffer.concat( [ rdata, encode_label( srv.target ) ]); - ngx.log( ngx.WARN, rdata.toString('hex')); return rdata; } @@ -569,46 +627,112 @@ function parse_soa_record(packet) { function parse_edns_options(packet) { - packet.edns = {} - packet.edns.opts = {} - packet.edns.size = packet.data.readUInt16BE(packet.offset); - packet.edns.rcode = packet.data[packet.offset+2]; - packet.edns.version = packet.data[packet.offset+3]; - packet.edns.z = packet.data.readUInt16BE(packet.offset+4); - packet.edns.rdlength = packet.data.readUInt16BE(packet.offset+6); + /** + NAME '' (name: root) + TYPE (2 bytes) 41 (type: OPT) + UDP payload size (2 bytes) .. (UDP payload size) + EDNS rcode (1 byte) 0 + ENDS version (1 byte) 0 + RESERVED 16bit 0 + RDLength 16bit int length of RDATA + RDATA variable length string containing 0, 1 or multiple + . OPCODE (2 bytes) + . OPLENGTH (2 bytes) + . OPDATA (variable length) + **/ + + packet.edns = { + opts: {}, + size: packet.data.readUInt16BE(packet.offset), + rcode: packet.data[packet.offset+2], + version: packet.data[packet.offset+3], + z: packet.data.readUInt16BE(packet.offset+4), + rdlength: packet.data.readUInt16BE(packet.offset+6) + }; packet.offset += 8; - var end = packet.offset + packet.edns.rdlength; - for ( ; packet.offset < end ; ) { + const end = packet.offset + packet.edns.rdlength; + while ( packet.offset < end ) { var opcode = packet.data.readUInt16BE(packet.offset); var oplength = packet.data.readUInt16BE(packet.offset+2); packet.offset += 4; - if ( opcode == 8 ) { - //client subnet - packet.edns.opts.csubnet = {} - packet.edns.opts.csubnet.family = packet.data.readUInt16BE(packet.offset); - packet.edns.opts.csubnet.netmask = packet.data[packet.offset+2]; - packet.edns.opts.csubnet.scope = packet.data[packet.offset+3]; - packet.offset += 4; - if ( packet.edns.opts.csubnet.family == 1 ) { - // IPv4 - var octet = [0,0,0,0]; - for (var i=4; i< oplength ; i++ ) { - octet[i-4] = packet.data[packet.offset++]; - } - packet.edns.opts.csubnet.subnet = octet.join("."); - break; - } else { - // We don't support IPv6 yet. - packet.edns.opts = {} - break; - } + if ( opcode == edns_opcode.CSUBNET ) { + // CSUBNET + const _family = packet.data.readUInt16BE(packet.offset); + const parse_arpa = (_family == 1 ? parse_arpa_v4 : parse_arpa_v6); + packet.edns.opts.csubnet = { + family: _family, + netmask: packet.data[packet.offset+2], + scope: packet.data[packet.offset+3], + subnet: parse_arpa( + packet.data.slice(packet.offset+4, packet.offset+oplength) + ) + }; } else { - // We only look for CSUBNET... Not interested in anything else at this time. - packet.offset += oplength; + // COOKIE etc. + packet.edns.opts[edns_opcode.value[opcode].toLowerCase()] = { + opdata: packet.data.slice(packet.offset, packet.offset + oplength) + }; } + packet.offset += oplength; } - } +function gen_edns_options(packet) { + /** + NAME '' (name: root) + TYPE (2 bytes) 41 (type: OPT) + UDP payload size (2 bytes) .. (UDP payload size) + EDNS rcode (1 byte) 0 + ENDS version (1 byte) 0 + RESERVED 16bit 0 + RDLength 16bit int length of RDATA + RDATA variable length string containing 0, 1 or multiple + . OPCODE (2 bytes) + . OPLENGTH (2 bytes) + . OPDATA (variable length) + **/ + + if ( 'edns' in packet ) { + let rdata = Buffer.alloc(0); + if ( 'csubnet' in packet.edns.opts ) { + const encode_arpa = ( + packet.edns.opts.csubnet.family == 1 ? + encode_arpa_v4 : // IPv4 + encode_arpa_v6); // IPv6 + const csubnet = Buffer.concat([ + to_bytes( packet.edns.opts.csubnet.family ), // i.e. 1 (IPv4) + Buffer.from([ packet.edns.opts.csubnet.netmask ]), // i.e. 24 (/24) + Buffer.from([ packet.edns.opts.csubnet.scope ]), // i.e. 0 + encode_arpa(packet.edns.opts.csubnet.subnet).slice( + 0, + Math.ceil(packet.edns.opts.csubnet.netmask/8) + ) // i.e. 10.2.3.x (hex, truncated to netmask) + ]); + rdata = Buffer.concat([ rdata, + to_bytes(edns_opcode.CSUBNET), + to_bytes(csubnet.length), + csubnet + ]); + } + if ( 'cookie' in packet.edns.opts ) { + rdata = Buffer.concat([ rdata, + to_bytes(edns_opcode.COOKIE), + to_bytes(packet.edns.opts.cookie.opdata.length), + packet.edns.opts.cookie.opdata + ]); + } + // TODO: treat other OPCODEs + return Buffer.concat([ + Buffer.from([0]), + to_bytes(dns_type.OPT), + to_bytes(packet.edns.size || 1232), + Buffer.from([packet.edns.rcode || 0]), + Buffer.from([packet.edns.version || 0]), + to_bytes(packet.edns.z || 0), + to_bytes(rdata.length), + rdata + ]); + } +}