sni.js 7.76 KB
"use strict";

var sni = module.exports;
var tls = require("tls");
var servernameRe = /^[a-z0-9\.\-]+$/i;

// a nice, round, irrational number - about every 6¼ hours
var refreshOffset = Math.round(Math.PI * 2 * (60 * 60 * 1000));
// and another, about 15 minutes
var refreshStagger = Math.round(Math.PI * 5 * (60 * 1000));
// and another, about 30 seconds
var smallStagger = Math.round(Math.PI * (30 * 1000));

//secureOpts.SNICallback = sni.create(greenlock, secureOpts);
sni.create = function(greenlock, secureOpts) {
    var _cache = {};
    var defaultServername = greenlock.servername || "";

    if (secureOpts.cert) {
        // Note: it's fine if greenlock.servername is undefined,
        // but if the caller wants this to auto-renew, they should define it
        _cache[defaultServername] = {
            refreshAt: 0,
            secureContext: tls.createSecureContext(secureOpts)
        };
    }

    return getSecureContext;

    function notify(ev, args) {
        try {
            // TODO _notify() or notify()?
            (greenlock.notify || greenlock._notify)(ev, args);
        } catch (e) {
            console.error(e);
            console.error(ev, args);
        }
    }

    function getSecureContext(servername, cb) {
        //console.log("debug sni", servername);
        if ("string" !== typeof servername) {
            // this will never happen... right? but stranger things have...
            console.error("[sanity fail] non-string servername:", servername);
            cb(new Error("invalid servername"), null);
            return;
        }

        var secureContext = getCachedContext(servername);
        if (secureContext) {
            //console.log("debug sni got cached context", servername, getCachedMeta(servername));
            cb(null, secureContext);
            return;
        }

        getFreshContext(servername)
            .then(function(secureContext) {
                if (secureContext) {
                    //console.log("debug sni got fresh context", servername, getCachedMeta(servername));
                    cb(null, secureContext);
                    return;
                }

                // Note: this does not replace tlsSocket.setSecureContext()
                // as it only works when SNI has been sent
                //console.log("debug sni got default context", servername, getCachedMeta(servername));
                if (!/PROD/.test(process.env.ENV) || /DEV|STAG/.test(process.env.ENV)) {
                    // Change this once
                    // A) the 'notify' message passing is verified fixed in cluster mode
                    // B) we have a good way to let people know their server isn't configured
                    console.debug("debug: ignoring servername " + JSON.stringify(servername));
                    console.debug("       (it's probably either missing from your config, or a bot)");
                    notify("servername_unknown", {
                        servername: servername
                    });
                }
                cb(null, getDefaultContext());
            })
            .catch(function(err) {
                if (!err.context) {
                    err.context = "sni_callback";
                }
                notify("error", err);
                //console.log("debug sni error", servername, err);
                cb(err);
            });
    }

    function getCachedMeta(servername) {
        var meta = _cache[servername];
        if (!meta) {
            if (!_cache[wildname(servername)]) {
                return null;
            }
        }
        return meta;
    }

    function getCachedContext(servername) {
        var meta = getCachedMeta(servername);
        if (!meta) {
            return null;
        }

        // always renew in background
        if (!meta.refreshAt || Date.now() >= meta.refreshAt) {
            getFreshContext(servername).catch(function(e) {
                if (!e.context) {
                    e.context = "sni_background_refresh";
                }
                notify("error", e);
            });
        }

        // under normal circumstances this would never be expired
        // and, if it is expired, something is so wrong it's probably
        // not worth wating for the renewal - it has probably failed
        return meta.secureContext;
    }

    function getFreshContext(servername) {
        var meta = getCachedMeta(servername);
        if (!meta && !validServername(servername)) {
            if ((servername && !/PROD/.test(process.env.ENV)) || /DEV|STAG/.test(process.env.ENV)) {
                // Change this once
                // A) the 'notify' message passing is verified fixed in cluster mode
                // B) we have a good way to let people know their server isn't configured
                console.debug("debug: invalid servername " + JSON.stringify(servername));
                console.debug("       (it's probably just a bot trolling for vulnerable servers)");
                notify("servername_invalid", {
                    servername: servername
                });
            }
            return Promise.resolve(null);
        }

        if (meta) {
            // prevent stampedes
            meta.refreshAt = Date.now() + randomRefreshOffset();
        }

        // TODO don't get unknown certs at all, rely on auto-updates from greenlock
        // Note: greenlock.get() will return an existing fresh cert or issue a new one
        return greenlock.get({ servername: servername }).then(function(result) {
            var meta = getCachedMeta(servername);
            if (!meta) {
                meta = _cache[servername] = { secureContext: { _valid: false } };
            }
            // prevent from being punked by bot trolls
            meta.refreshAt = Date.now() + smallStagger;

            // nothing to do
            if (!result) {
                return null;
            }

            // we only care about the first one
            var pems = result.pems;
            var site = result.site;
            if (!pems || !pems.cert) {
                // nothing to do
                // (and the error should have been reported already)
                return null;
            }

            meta = {
                refreshAt: Date.now() + randomRefreshOffset(),
                secureContext: tls.createSecureContext({
                    // TODO support passphrase-protected privkeys
                    key: pems.privkey,
                    cert: pems.cert + "\n" + pems.chain + "\n"
                })
            };
            meta.secureContext._valid = true;

            // copy this same object into every place
            (result.altnames || site.altnames || [result.subject || site.subject]).forEach(function(altname) {
                _cache[altname] = meta;
            });

            return meta.secureContext;
        });
    }

    function getDefaultContext() {
        return getCachedContext(defaultServername);
    }
};

// whenever we need to know when to refresh next
function randomRefreshOffset() {
    var stagger = Math.round(refreshStagger / 2) - Math.round(Math.random() * refreshStagger);
    return refreshOffset + stagger;
}

function validServername(servername) {
    // format and (lightly) sanitize sni so that users can be naive
    // and not have to worry about SQL injection or fs discovery

    servername = (servername || "").toLowerCase();
    // hostname labels allow a-z, 0-9, -, and are separated by dots
    // _ is sometimes allowed, but not as a "hostname", and not by Let's Encrypt ACME
    // REGEX // https://www.codeproject.com/Questions/1063023/alphanumeric-validation-javascript-without-regex
    return servernameRe.test(servername) && -1 === servername.indexOf("..");
}

function wildname(servername) {
    return (
        "*." +
        servername
            .split(".")
            .slice(1)
            .join(".")
    );
}