index.js
'use strict';
const fs = require('fs');
const path = require('path');
const punycode = require('punycode.js');
const update = require('./lib/update');
const regex = {
comment: /^\s*[;#].*$/,
blank: /^\s*$/,
line: /^\s*(.*?)\s*$/,
}
const logger = {
log: (message) => {
switch (process.env.HARAKA_LOGS_SUPPRESS) {
case undefined:
case "false":
case "0":
console.log(message);
}
},
};
module.exports = exports = {
public_suffix_list: {},
top_level_tlds: {},
two_level_tlds: {},
three_level_tlds: {},
}
function normalizeHost (host) {
host = host.toLowerCase();
if (/^xn--|\.xn--/.test(host)) {
try { host = punycode.toUnicode(host); }
catch (ignore) {}
}
return host;
}
exports.is_public_suffix = function (host) {
if (!host) return false;
host = normalizeHost(host);
if (exports.public_suffix_list[host]) return true;
const up_one_level = host.split('.').slice(1).join('.'); // co.uk -> uk
if (!up_one_level) return false; // no dot?
const wildHost = `*.${ up_one_level}`;
if (exports.public_suffix_list[wildHost]) {
// check exception list
if (exports.public_suffix_list[`!${host}`]) return false;
return true; // matched a wildcard, ex: *.uk
}
return false;
}
exports.get_organizational_domain = function (host) {
// the domain that was registered with a domain name registrar. See
// https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1
// section 3.2
if (!host) return null;
host = normalizeHost(host);
// www.example.com -> [ com, example, www ]
const labels = host.split('.').reverse();
// 4.3 Search the public suffix list for the name that matches the
// largest number of labels found in the subject DNS domain.
let greatest = 0;
for (let i = 1; i <= labels.length; i++) {
if (!labels[i-1]) return null; // dot w/o label
const tld = labels.slice(0,i).reverse().join('.');
if (exports.is_public_suffix(tld)) {
greatest = +(i + 1);
}
else if (exports.public_suffix_list[`!${tld}`]) {
greatest = i;
}
}
// 4.4 Construct a new DNS domain name using the name that matched
// from the public suffix list and prefixing to it the "x+1"th
// label from the subject domain.
if (greatest === 0) return null; // no valid TLD
if (greatest > labels.length) return null; // not enough labels
if (greatest === labels.length) return host; // same
const orgName = labels.slice(0, greatest).reverse().join('.');
return orgName;
}
exports.split_hostname = function (host, level) {
if (!level || (level && !(level >= 1 && level <= 3))) {
level = 2;
}
const split = host.toLowerCase().split(/\./).reverse();
let domain = '';
// TLD
if (level >= 1 && split[0] && exports.top_level_tlds[split[0]]) {
domain = split.shift() + domain;
}
// 2nd TLD
if (level >= 2 && split[0] && exports.two_level_tlds[`${split[0]}.${domain}`]) {
domain = `${split.shift()}.${domain}`;
}
// 3rd TLD
if (level >= 3 && split[0] && exports.three_level_tlds[`${split[0]}.${domain}`]) {
domain = `${split.shift()}.${domain}`;
}
// Domain
if (split[0]) {
domain = `${split.shift()}.${domain}`;
}
return [split.reverse().join('.'), domain];
}
function load_public_suffix_list () {
load_list_from_file('public-suffix-list').forEach((entry) => {
// Parsing rules: http://publicsuffix.org/list/
// Each line is only read up to the first whitespace
const suffix = entry.split(/\s/).shift();
// Each line which is not entirely whitespace or begins with a comment
// contains a rule.
if (!suffix) return; // empty string
if ('/' === suffix.substring(0,1)) return; // comment
// A rule may begin with a "!" (exclamation mark). If it does, it is
// labelled as a "exception rule" and then treated as if the exclamation
// mark is not present.
if ('!' === suffix.substring(0,1)) {
const eName = suffix.substring(1); // remove ! prefix
// bbc.co.uk -> co.uk
const up_one = suffix.split('.').slice(1).join('.');
if (exports.public_suffix_list[up_one]) {
exports.public_suffix_list[up_one].push(eName);
}
else if (exports.public_suffix_list[`*.${up_one}`]) {
exports.public_suffix_list[`*.${up_one}`].push(eName);
}
else {
console.error(`unable to find parent for exception: ${eName}`);
}
}
exports.public_suffix_list[suffix] = [];
})
logger.log(`loaded ${Object.keys(exports.public_suffix_list).length} Public Suffixes`);
}
function load_tld_files () {
load_list_from_file('top-level-tlds').forEach(function (tld) {
exports.top_level_tlds[tld] = 1;
})
load_list_from_file('two-level-tlds').forEach(function (tld) {
exports.two_level_tlds[tld] = 1;
})
load_list_from_file('three-level-tlds').forEach(function (tld) {
exports.three_level_tlds[tld] = 1;
})
load_list_from_file('extra-tlds').forEach(function (tld) {
const s = tld.split(/\./);
if (s.length === 2) {
exports.two_level_tlds[tld] = 1;
}
else if (s.length === 3) {
exports.three_level_tlds[tld] = 1;
}
})
logger.log(`loaded TLD files:
1=${Object.keys(exports.top_level_tlds).length}
2=${Object.keys(exports.two_level_tlds).length}
3=${Object.keys(exports.three_level_tlds).length}`
);
}
function load_list_from_file (name) {
const result = [];
let filePath = path.resolve(__dirname, 'etc', name);
if (!fs.existsSync(filePath)) {
// not loaded by Haraka, use local path
filePath = path.resolve('etc', name);
}
fs.readFileSync(filePath, 'UTF-8')
.split(/\r\n|\r|\n/)
.forEach((line) => {
if (regex.comment.test(line)) return;
if (regex.blank.test(line)) return;
const line_data = regex.line.exec(line);
if (!line_data) return;
result.push(line_data[1].trim().toLowerCase());
});
return result;
}
load_tld_files();
load_public_suffix_list();
// every 15 days, check for an update. If updated, download, install,
// and then read it into the exported object
setInterval(() => {
update.updatePSLfile().then(updated => {
if (updated) load_public_suffix_list();
}).catch(err => {
console.error(err.message)
});
}, 15 * 86400 * 1000).unref(); // each 15 days
// the .unref() on the interval tells node to ignore this
// timer when deciding whether the process is done.