/* jslint node: true */ 'use strict'; // ENiGMA½ const Log = require('../../logger.js').log; const { ServerModule } = require('../../server_module.js'); const Config = require('../../config.js').get; const { getMessageAreaByTag, getMessageConferenceByTag, getMessageListForArea, } = require('../../message_area.js'); const User = require('../../user.js'); const Errors = require('../../enig_error.js').Errors; const Message = require('../../message.js'); const FTNAddress = require('../../ftn_address.js'); const { isAnsi, cleanControlCodes, splitTextAtTerms, } = require('../../string_util.js'); const AnsiPrep = require('../../ansi_prep.js'); // deps const NNTPServerBase = require('nntp-server'); const _ = require('lodash'); const fs = require('fs-extra'); const asyncReduce = require('async/reduce'); const asyncMap = require('async/map'); const asyncSeries = require('async/series'); const LRU = require('lru-cache'); // // Network News Transfer Protocol (NNTP) // // RFCS // - https://www.w3.org/Protocols/rfc977/rfc977 // - https://tools.ietf.org/html/rfc3977 // - https://tools.ietf.org/html/rfc2980 // - https://tools.ietf.org/html/rfc5536 // exports.moduleInfo = { name : 'NNTP', desc : 'Network News Transfer Protocol (NNTP) Server', author : 'NuSkooler', packageName : 'codes.l33t.enigma.nntp.server', }; /* General TODO - ACS checks need worked out. Currently ACS relies on |client|. We need a client spec that can be created even without a login server. Some checks and simply return false/fail. */ class NNTPServer extends NNTPServerBase { constructor(options, serverName) { super(options); this.log = Log.child( { server : serverName } ); const config = Config(); this.groupCache = new LRU({ max : _.get(config, 'contentServers.nntp.cache.maxItems', 200), maxAge : _.get(config, 'contentServers.nntp.cache.maxAge', 1000 * 30), // default=30s }); } _needAuth(session, command) { return super._needAuth(session, command); } _authenticate(session) { const username = session.authinfo_user; const password = session.authinfo_pass; this.log.trace( { username }, 'Authentication request'); return new Promise( resolve => { const user = new User(); user.authenticate(username, password, err => { if(err) { this.log.debug( { username, reason : err.message }, 'Authentication failure'); return resolve(false); } session.authUser = user; this.log.debug( { username }, 'User authenticated successfully'); return resolve(true); }); }); } getMessageListIndexByMessageID(id, session) { return id - _.get(session.groupInfo.messageList, [ 0, 'messageId' ]); } isGroupSelected(session) { return Array.isArray(_.get(session, 'groupInfo.messageList')); } getJAMStyleFrom(message, fromName) { // // Try to to create a (JamNTTPd) JAM style "From" field: // // - If we're dealing with a FTN address, create an email-like format // but do not include ':' or '/' characters as it may cause clients // to puke. FTN addresses are formatted how JamNTTPd does it for // some sort of compliance. We also extend up to 5D addressing. // - If we have an email address, then it's ready to go. // const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]); let jamStyleFrom; if(remoteFrom) { const flavor = _.get(message.meta, [ 'System', Message.SystemMetaNames.ExternalFlavor ]); switch(flavor) { case [ Message.AddressFlavor.FTN ] : { let ftnAddr = FTNAddress.fromString(remoteFrom); if(ftnAddr && ftnAddr.isValid()) { // In general, addresses are in point, node, net, zone, domain order if(ftnAddr.domain) { // 5D // point.node.net.zone@domain or node.net.zone@domain jamStyleFrom = `${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}@${ftnAddr.domain}`; if(ftnAddr.point) { jamStyleFrom = `${ftnAddr.point}.` + jamStyleFrom; } } else { if(ftnAddr.point) { jamStyleFrom = `${ftnAddr.point}@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`; } else { jamStyleFrom = `0@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`; } } } } break; case [ Message.AddressFlavor.Email ] : jamStyleFrom = `${fromName} <${remoteFrom}>`; break; } } if(!jamStyleFrom) { jamStyleFrom = fromName; } return jamStyleFrom; } populateNNTPHeaders(session, message, cb) { // // Build compliant headers // // Resources: // - https://tools.ietf.org/html/rfc5536#section-3.1 // - https://github.com/ftnapps/jamnntpd/blob/master/src/nntpserv.c#L962 // const toName = this.getMessageTo(message); const fromName = this.getMessageFrom(message); message.nntpHeaders = { From : this.getJAMStyleFrom(message, fromName), 'X-Comment-To' : toName, Newsgroups : session.group.name, Subject : message.subject, Date : this.getMessageDate(message), 'Message-ID' : this.getMessageIdentifier(message), Path : 'ENiGMA1/2!not-for-mail', 'Content-Type' : 'text/plain; charset=utf-8', }; const externalFlavor = _.get(message.meta.System, [ Message.SystemMetaNames.ExternalFlavor ]); if(externalFlavor) { message.nntpHeaders['X-ENiG-MessageFlavor'] = externalFlavor; } // Any FTN properties -> X-FTN-* _.each(message.meta.FtnProperty, (v, k) => { const suffix = { [ Message.FtnPropertyNames.FtnTearLine ] : 'Tearline', [ Message.FtnPropertyNames.FtnOrigin ] : 'Origin', [ Message.FtnPropertyNames.FtnArea ] : 'AREA', [ Message.FtnPropertyNames.FtnSeenBy ] : 'SEEN-BY', }[k]; if(suffix) { // some special treatment. if('Tearline' === suffix) { v = v.replace(/^--- /, ''); } else if('Origin' === suffix) { v = v.replace(/^[ ]{1,2}\* Origin: /, ''); } if(Array.isArray(v)) { // ie: SEEN-BY[] -> one big list v = v.join(' '); } message.nntpHeaders[`X-FTN-${suffix}`] = v.trim(); } }); // Other FTN kludges _.each(message.meta.FtnKludge, (v, k) => { if(Array.isArray(v)) { v = v.join(' '); // same as above } message.nntpHeaders[`X-FTN-${k.toUpperCase()}`] = v.toString().trim(); }); // // Set X-FTN-To and X-FTN-From: // - If remote to/from : joeuser // - Without remote : joeuser // const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]); message.nntpHeaders['X-FTN-From'] = remoteFrom ? `${fromName} <${remoteFrom}>` : fromName; const remoteTo = _.get(message.meta [ 'System', Message.SystemMetaNames.RemoteToUser ]); message.nntpHeaders['X-FTN-To'] = remoteTo ? `${toName} <${remoteTo}>` : toName; if(!message.replyToMsgId) { return cb(null); } // replyToMessageId -> Message-ID formatted ID const filter = { resultType : 'uuid', ids : [ parseInt(message.replyToMsgId) ], limit : 1, }; Message.findMessages(filter, (err, uuids) => { if(!err && Array.isArray(uuids)) { message.nntpHeaders.References = this.makeMessageIdentifier(message.replyToMsgId, uuids[0]); } return cb(null); }); } getMessageUUIDFromMessageID(session, messageId) { let messageUuid; // Direct ID request if((_.isString(messageId) && '<' !== messageId.charAt(0)) || _.isNumber(messageId)) { // group must be in session if(!this.isGroupSelected(session)) { return null; } messageId = parseInt(messageId); if(isNaN(messageId)) { return null; } // // Adjust to offset in message list & get UUID // This works since we create "pseudo IDs" to return to NNTP // by using firstRealID + index. A find on |index| member would // also work, but would be O(n). // const mlIndex = this.getMessageListIndexByMessageID(messageId, session); messageUuid = _.get(session.groupInfo.messageList, [ mlIndex, 'messageUuid']); } else { // request [ , messageUuid ] = this.getMessageIdentifierParts(messageId); } if(!_.isString(messageUuid)) { return null; } return messageUuid; } _getArticle(session, messageId) { return new Promise( resolve => { this.log.trace( { messageId }, 'Get article'); const messageUuid = this.getMessageUUIDFromMessageID(session, messageId); if(!messageUuid) { this.log.debug( { messageId }, 'Unable to retrieve message UUID for article request'); return resolve(null); } const message = new Message(); asyncSeries( [ (callback) => { return message.load( { uuid : messageUuid }, callback); }, (callback) => { if(!_.has(session, 'groupInfo.areaTag')) { // :TODO: if this is needed, how to validate properly? this.log.warn( { messageUuid, messageId }, 'Get article request without group selection'); return resolve(null); } if(session.groupInfo.areaTag !== message.areaTag) { return resolve(null); } if(!this.hasConfAndAreaReadAccess(session, session.groupInfo.confTag, session.groupInfo.areaTag)) { this.log.info( { messageUuid, messageId}, 'Access denied for message'); return resolve(null); } return callback(null); }, (callback) => { return this.populateNNTPHeaders(session, message, callback); }, (callback) => { return this.prepareMessageBody(message, callback); } ], err => { if(err) { this.log.error( { error : err.message, messageUuid }, 'Failed to load article'); return resolve(null); } this.log.info( { messageUuid, messageId, areaTag : message.areaTag }, 'Serving article'); return resolve(message); } ); }); } _getRange(session, first, last, options) { return new Promise(resolve => { // // Build an array of message objects that can later // be used with the various _build* methods. // // Messages must belong to the range of *pseudo IDs* // aka |index|. // // :TODO: Handle |options| if(!this.isGroupSelected(session)) { return resolve(null); } const uuids = session.groupInfo.messageList.filter(m => { if(m.areaTag !== session.groupInfo.areaTag) { return false; } if(m.index < first || m.index > last) { return false; } return true; }).map(m => { return { uuid : m.messageUuid, index : m.index } }); asyncMap(uuids, (msgInfo, nextMessageUuid) => { const message = new Message(); message.load( { uuid : msgInfo.uuid }, err => { if(err) { return nextMessageUuid(err); } message.index = msgInfo.index; this.populateNNTPHeaders(session, message, () => { this.prepareMessageBody(message, () => { return nextMessageUuid(null, message); }); }); }); }, (err, messages) => { return resolve(err ? null : messages); }); }); } _selectGroup (session, groupName) { this.log.trace( { groupName }, 'Select group request'); return new Promise( resolve => { this.getGroup(session, groupName, (err, group) => { if(err) { return resolve(false); } session.group = Object.assign( {}, // start clean { description : group.friendlyDesc || group.friendlyName, current_article : group.nntp.total ? group.nntp.min_index : 0, }, group.nntp ); session.groupInfo = group; // full set of info return resolve(true); }); }); } _getGroups(session, time, wildmat) { this.log.trace( { time, wildmat }, 'Get groups request'); // :TODO: handle time - probably use as caching mechanism - must consider user/auth/rights // :TODO: handle |time| if possible. return new Promise( (resolve, reject) => { const config = Config(); // :TODO: merge confs avail to authenticated user const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {}); asyncReduce(Object.keys(publicConfs), [], (groups, confTag, nextConfTag) => { const areaTags = publicConfs[confTag]; // :TODO: merge area tags available to authenticated user asyncMap(areaTags, (areaTag, nextAreaTag) => { const groupName = this.getGroupName(confTag, areaTag); // filter on |wildmat| if supplied. We will remove // empty areas below in the final results. if(wildmat && !wildmat.test(groupName)) { return nextAreaTag(null, null); } this.getGroup(session, groupName, (err, group) => { if(err) { return nextAreaTag(null, null); // try others } return nextAreaTag(null, group.nntp); }); }, (err, areas) => { if(err) { return nextConfTag(err); } areas = areas.filter(a => a && Object.keys(a).length > 0); // remove empty groups.push(...areas); return nextConfTag(null, groups); }); }, (err, groups) => { if(err) { return reject(err); } return resolve(groups); }); }); } isConfAndAreaPubliclyExposed(confTag, areaTag) { const publicAreaTags = _.get(Config(), [ 'contentServers', 'nntp', 'publicMessageConferences', confTag ] ); return Array.isArray(publicAreaTags) && publicAreaTags.includes(areaTag); } hasConfAndAreaReadAccess(session, confTag, areaTag) { if(Message.isPrivateAreaTag(areaTag)) { return false; } if(this.isConfAndAreaPubliclyExposed(confTag, areaTag)) { return true; } // further checks require an authenticated user & ACS if(!session || !session.authUser) { return false; } const conf = getMessageConferenceByTag(confTag); if(!conf) { return false; } // :TODO: validate ACS const area = getMessageAreaByTag(areaTag, confTag); if(!area) { return false; } // :TODO: validate ACS return false; } getGroup(session, groupName, cb) { let group = this.groupCache.get(groupName); if(group) { return cb(null, group); } const [ confTag, areaTag ] = groupName.split('.'); if(!confTag || !areaTag) { return cb(Errors.UnexpectedState(`Invalid NNTP group name: ${groupName}`)); } if(!this.hasConfAndAreaReadAccess(session, confTag, areaTag)) { return cb(Errors.AccessDenied(`No access to conference ${confTag} and/or area ${areaTag}`)); } const area = getMessageAreaByTag(areaTag, confTag); if(!area) { return cb(Errors.DoesNotExist(`No area for areaTag "${areaTag}" / confTag "${confTag}"`)); } getMessageListForArea(null, areaTag, (err, messageList) => { if(err) { return cb(err); } if(0 === messageList.length) { // // Handle empty group // See https://tools.ietf.org/html/rfc3977#section-6.1.1.2 // return cb(null, { messageList : [], confTag, areaTag, friendlyName : area.name, friendlyDesc : area.desc, nntp : { name : groupName, description : area.desc, min_index : 0, max_index : 0, total : 0, } }); } const firstMsg = messageList[0]; // node-nntp wants "index" let index = firstMsg.messageId; messageList.forEach(m => { m.index = index; ++index; }); group = { messageList, confTag, areaTag, friendlyName : area.name, friendlyDesc : area.desc, nntp : { name : groupName, min_index : firstMsg.messageId, max_index : firstMsg.messageId + messageList.length - 1, total : messageList.length, }, }; this.groupCache.set(groupName, group); return cb(null, group); }); } _buildHead(session, message) { return _.map(message.nntpHeaders, (v, k) => `${k}: ${v}`).join('\r\n'); } _buildBody(session, message) { return message.preparedBody; } _buildHeaderField(session, message, field) { const body = message.preparedBody || message.message; const value = { ':bytes' : Buffer.byteLength(body).toString(), ':lines' : splitTextAtTerms(body).length.toString(), }[field] || _.find(message.nntpHeaders, (v, k) => { return k.toLowerCase() === field; }); if(!value) { // // Clients will check some headers just to see if they exist. // Don't spam logs with these. For others, it's good to know. // if(!['references', 'xref'].includes(field)) { this.log.trace(`No value for requested header field "${field}"`); } } return value; } _getOverviewFmt(session) { return super._getOverviewFmt(session); } _getNewNews(session, time, wildmat) { // Currently seems pointless to implement. No semi-modern clients seem to use it anyway. this.log.debug( { time, wildmat }, 'Request made using unsupported NEWNEWS command'); throw new Errors.Invalid('NEWNEWS is not enabled on this server'); } getMessageDate(message) { // https://tools.ietf.org/html/rfc5536#section-3.1.1 -> https://tools.ietf.org/html/rfc5322#section-3.3 return message.modTimestamp.format('ddd, D MMM YYYY HH:mm:ss ZZ'); } makeMessageIdentifier(messageId, messageUuid) { // // Spec : RFC-5536 Section 3.1.3 @ https://tools.ietf.org/html/rfc5536#section-3.1.3 // Example : <2456.0f6587f7-5512-4d03-8740-4d592190145a@enigma-bbs> // return `<${messageId}.${messageUuid}@enigma-bbs>`; } getMessageIdentifier(message) { return this.makeMessageIdentifier(message.messageId, message.messageUuid); } getMessageIdentifierParts(messageId) { const m = messageId.match(/<([0-9]+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@enigma-bbs>/); if(m) { return [ m[1], m[2] ]; } return []; } getMessageTo(message) { // :TODO: same as From -- check config return message.toUserName; } getMessageFrom(message) { // :TODO: NNTP config > conf > area config for real names return message.fromUserName; } prepareMessageBody(message, cb) { if(isAnsi(message.message)) { AnsiPrep( message.message, { rows : 'auto', cols : 79, forceLineTerm : true, asciiMode : true, fillLines : false, }, (err, prepped) => { message.preparedBody = prepped || message.message; return cb(null); } ); } else { message.preparedBody = cleanControlCodes(message.message, { all : true }); return cb(null); } } getGroupName(confTag, areaTag) { // // Example: // input : fsxNet (confTag) fsx_bbs (areaTag) // output: fsx_net.fsx_bbs // // Note also that periods are replaced in conf and area // tags such that we *only* have a period separator // between the two for a group name! // return `${_.snakeCase(confTag).replace(/\./g, '_')}.${_.snakeCase(areaTag).replace(/\./g, '_')}`; } } exports.getModule = class NNTPServerModule extends ServerModule { constructor() { super(); } isEnabled() { return this.enableNntp || this.enableNttps; } get enableNntp() { return _.get(Config(), 'contentServers.nntp.nntp.enabled', false); } get enableNttps() { return _.get(Config(), 'contentServers.nntp.nntps.enabled', false); } isConfigured() { const config = Config(); // // Any conf/areas exposed? // const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {}); const areasExposed = _.some(publicConfs, areas => { return Array.isArray(areas) && areas.length > 0; }); if(!areasExposed) { return false; } const nntp = _.get(config, 'contentServers.nntp.nntp'); if(nntp && this.enableNntp) { if(isNaN(nntp.port)) { return false; } } const nntps = _.get(config, 'contentServers.nntp.nntps'); if(nntps && this.enableNttps) { if(isNaN(nntps.port)) { return false; } if(!_.isString(nntps.certPem) || !_.isString(nntps.keyPem)) { return false; } } return true; } createServer() { if(!this.isEnabled() || !this.isConfigured()) { return; } const config = Config(); const commonOptions = { //requireAuth : true, // :TODO: re-enable! // :TODO: override |session| - use our own debug to Bunyan, etc. }; if(this.enableNntp) { this.nntpServer = new NNTPServer( // :TODO: according to docs: if connection is non-tls, but behind proxy (assuming TLS termination?!!) then set this to true Object.assign( { secure : false }, commonOptions), 'NNTP' ); } if(this.enableNttps) { this.nntpsServer = new NNTPServer( Object.assign( { secure : true, tls : { cert : fs.readFileSync(config.contentServers.nntp.nntps.certPem), key : fs.readFileSync(config.contentServers.nntp.nntps.keyPem), } }, commonOptions ), 'NTTPS' ); } } listen() { const config = Config(); [ 'nntp', 'nntps' ].forEach( service => { const server = this[`${service}Server`]; if(server) { const port = config.contentServers.nntp[service].port; server.listen(this.listenURI(port, service)) .catch(e => { Log.warn( { error : e.message, port }, `${service.toUpperCase()} failed to listen`); }); } }); // :TODO: listen() needs to be async. I always should have been... return true; } listenURI(port, service = 'nntp') { return `${service}://0.0.0.0:${port}`; } };