diff --git a/config/default.toml b/config/default.toml index 3636ed6a..5ed929c8 100644 --- a/config/default.toml +++ b/config/default.toml @@ -66,6 +66,9 @@ bugsnagCode="" [api] # @include "api.toml" +[irc] +# @include "irc.toml" + [sender] # @include "sender.toml" diff --git a/config/irc.toml b/config/irc.toml new file mode 100644 index 00000000..e0d9367b --- /dev/null +++ b/config/irc.toml @@ -0,0 +1,25 @@ +# If enabled then Wild Duck exposes an IRC interface for (authenticated) chat +enabled=false # IRC server is not enabled by default +port=6667 +# by default bind to localhost only +host="127.0.0.1" + +# Use `true` for port 6697 and `false` for 6667. Try to always use `true` on production +secure=false + +[motd] +source="message" # "message" or "file" +#file="/path/to/motd" +message="Set MOTD message with the server config option \u0002irc.motd\u0002" + +[tls] +# If certificate path is not defined, use global or built-in self-signed certs +#key="/path/to/server/key.pem" +#cert="/path/to/server/cert.pem" + +[setup] +# Public configuration for IRC +hostname="localhost" +secure=true +# port defaults to irc.port +#port=6697 diff --git a/irc.js b/irc.js new file mode 100644 index 00000000..f66fe110 --- /dev/null +++ b/irc.js @@ -0,0 +1,101 @@ +'use strict'; + +const config = require('wild-config'); +const log = require('npmlog'); +const IRCServer = require('./lib/irc/server'); +const UserHandler = require('./lib/user-handler'); +const MessageHandler = require('./lib/message-handler'); +const db = require('./lib/db'); +const fs = require('fs'); +const certs = require('./lib/certs').get('irc'); + +const serverOptions = { + port: config.irc.port, + host: config.irc.host, + secure: config.irc.secure, + + // log to console + logger: { + info(...args) { + args.shift(); + log.info('IRC', ...args); + }, + debug(...args) { + args.shift(); + log.silly('IRC', ...args); + }, + error(...args) { + args.shift(); + log.error('IRC', ...args); + } + } +}; + +if (certs) { + serverOptions.key = certs.key; + if (certs.ca) { + serverOptions.ca = certs.ca; + } + serverOptions.cert = certs.cert; +} + +const server = new IRCServer(serverOptions); + +config.on('reload', () => { + // update message of the day + updateMotd(); +}); + +updateMotd(); + +module.exports = done => { + if (!config.irc.enabled) { + return setImmediate(() => done(null, false)); + } + + let started = false; + + server.messageHandler = new MessageHandler({ + database: db.database, + redis: db.redis, + gridfs: db.gridfs, + attachments: config.attachments + }); + + server.userHandler = new UserHandler({ + database: db.database, + users: db.users, + redis: db.redis, + authlogExpireDays: config.log.authlogExpireDays + }); + + server.on('error', err => { + if (!started) { + started = true; + return done(err); + } + log.error('IRC', err); + }); + + server.listen(config.irc.port, config.irc.host, () => { + if (started) { + return server.close(); + } + started = true; + done(null, server); + }); +}; + +function updateMotd() { + if (config.irc.motd.source === 'message') { + server.motd = config.irc.motd.message; + } else if (config.irc.motd.source === 'file') { + fs.readFile(config.irc.motd.file, 'utf-8', (err, motd) => { + if (err) { + log.error('IRC', 'Failed to realod MOTD. %s', err.message); + return; + } + server.motd = motd; + }); + } +} diff --git a/lib/api/asps.js b/lib/api/asps.js index 55059749..3097026f 100644 --- a/lib/api/asps.js +++ b/lib/api/asps.js @@ -11,7 +11,11 @@ module.exports = (db, server, userHandler) => { res.charSet('utf-8'); const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required() + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required() }); const result = Joi.validate(req.params, schema, { @@ -86,10 +90,25 @@ module.exports = (db, server, userHandler) => { res.charSet('utf-8'); const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - description: Joi.string().trim().max(255).required(), - scopes: Joi.array().items(Joi.string().valid('imap', 'pop3', 'smtp', '*').required()).unique(), - generateMobileconfig: Joi.boolean().truthy(['Y', 'true', 'yes', 1]).default(false), + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + description: Joi.string() + .trim() + .max(255) + .required(), + scopes: Joi.array() + .items( + Joi.string() + .valid('imap', 'pop3', 'smtp', 'irc', '*') + .required() + ) + .unique(), + generateMobileconfig: Joi.boolean() + .truthy(['Y', 'true', 'yes', 1]) + .default(false), ip: Joi.string().ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' @@ -97,7 +116,10 @@ module.exports = (db, server, userHandler) => { }); if (typeof req.params.scopes === 'string') { - req.params.scopes = req.params.scopes.split(',').map(scope => scope.trim()).filter(scope => scope); + req.params.scopes = req.params.scopes + .split(',') + .map(scope => scope.trim()) + .filter(scope => scope); } const result = Joi.validate(req.params, schema, { @@ -169,7 +191,10 @@ module.exports = (db, server, userHandler) => { let profileOpts = {}; Object.keys(config.api.mobileconfig || {}).forEach(key => { - profileOpts[key] = (config.api.mobileconfig[key] || '').toString().replace(/\{email\}/g, userData.address).trim(); + profileOpts[key] = (config.api.mobileconfig[key] || '') + .toString() + .replace(/\{email\}/g, userData.address) + .trim(); }); let options = { @@ -220,8 +245,16 @@ module.exports = (db, server, userHandler) => { res.charSet('utf-8'); const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - asp: Joi.string().hex().lowercase().length(24).required(), + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + asp: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), ip: Joi.string().ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' diff --git a/lib/attachments/gridstore-storage.js b/lib/attachments/gridstore-storage.js index ec0298c6..71950cc3 100644 --- a/lib/attachments/gridstore-storage.js +++ b/lib/attachments/gridstore-storage.js @@ -3,6 +3,8 @@ const GridFSBucket = require('mongodb').GridFSBucket; const libbase64 = require('libbase64'); +const FEATURE_DECODE_ATTACHMENTS = false; + class GridstoreStorage { constructor(options) { this.bucketName = (options.options && options.options.bucket) || 'attachments'; @@ -53,7 +55,7 @@ class GridstoreStorage { } }); - if (attachment.transferEncoding === 'base64' && this.decodeBase64) { + if (FEATURE_DECODE_ATTACHMENTS && attachment.transferEncoding === 'base64' && this.decodeBase64) { let lineLen = 0; let expectBr = false; //find out the length of first line diff --git a/lib/certs.js b/lib/certs.js index fcdc7627..aca626db 100644 --- a/lib/certs.js +++ b/lib/certs.js @@ -6,7 +6,7 @@ const fs = require('fs'); const certs = new Map(); // load certificate files -[false, 'imap', 'lmtp', 'pop3', 'api', 'api.mobileconfig'].forEach(type => { +[false, 'imap', 'lmtp', 'pop3', 'api', 'irc', 'api.mobileconfig'].forEach(type => { let tlsconf = config.tls; if (type) { diff --git a/lib/irc/codes.js b/lib/irc/codes.js new file mode 100644 index 00000000..7a3e60cf --- /dev/null +++ b/lib/irc/codes.js @@ -0,0 +1,493 @@ +'use strict'; + +module.exports = new Map([ + ['RPL_WELCOME', '001'], + ['RPL_YOURHOST', '002'], + ['RPL_CREATED', '003'], + ['RPL_MYINFO', '004'], + ['RPL_BOUNCE', '005'], + ['RPL_ISUPPORT', '005'], + ['RPL_MAP', '006'], + ['RPL_MAPEND', '007'], + ['RPL_SNOMASK', '008'], + ['RPL_STATMEMTOT', '009'], + ['RPL_BOUNCE', '010'], + ['RPL_STATMEM', '010'], + ['RPL_YOURCOOKIE', '014'], + ['RPL_MAP', '015'], + ['RPL_MAPMORE', '016'], + ['RPL_MAPEND', '017'], + ['RPL_YOURID', '042'], + ['RPL_SAVENICK', '043'], + ['RPL_ATTEMPTINGJUNC', '050'], + ['RPL_ATTEMPTINGREROUTE', '051'], + ['RPL_TRACELINK', '200'], + ['RPL_TRACECONNECTING', '201'], + ['RPL_TRACEHANDSHAKE', '202'], + ['RPL_TRACEUNKNOWN', '203'], + ['RPL_TRACEOPERATOR', '204'], + ['RPL_TRACEUSER', '205'], + ['RPL_TRACESERVER', '206'], + ['RPL_TRACESERVICE', '207'], + ['RPL_TRACENEWTYPE', '208'], + ['RPL_TRACECLASS', '209'], + ['RPL_TRACERECONNECT', '210'], + ['RPL_STATS', '210'], + ['RPL_STATSLINKINFO', '211'], + ['RPL_STATSCOMMANDS', '212'], + ['RPL_STATSCLINE', '213'], + ['RPL_STATSNLINE', '214'], + ['RPL_STATSILINE', '215'], + ['RPL_STATSKLINE', '216'], + ['RPL_STATSQLINE', '217'], + ['RPL_STATSPLINE', '217'], + ['RPL_STATSYLINE', '218'], + ['RPL_ENDOFSTATS', '219'], + ['RPL_STATSPLINE', '220'], + ['RPL_STATSBLINE', '220'], + ['RPL_UMODEIS', '221'], + ['RPL_MODLIST', '222'], + ['RPL_STATSBLINE', '222'], + ['RPL_STATSELINE', '223'], + ['RPL_STATSGLINE', '223'], + ['RPL_STATSFLINE', '224'], + ['RPL_STATSTLINE', '224'], + ['RPL_STATSDLINE', '225'], + ['RPL_STATSZLINE', '225'], + ['RPL_STATSELINE', '225'], + ['RPL_STATSCOUNT', '226'], + ['RPL_STATSNLINE', '226'], + ['RPL_STATSGLINE', '227'], + ['RPL_STATSVLINE', '227'], + ['RPL_STATSQLINE', '228'], + ['RPL_SERVICEINFO', '231'], + ['RPL_ENDOFSERVICES', '232'], + ['RPL_RULES', '232'], + ['RPL_SERVICE', '233'], + ['RPL_SERVLIST', '234'], + ['RPL_SERVLISTEND', '235'], + ['RPL_STATSVERBOSE', '236'], + ['RPL_STATSENGINE', '237'], + ['RPL_STATSFLINE', '238'], + ['RPL_STATSIAUTH', '239'], + ['RPL_STATSVLINE', '240'], + ['RPL_STATSXLINE', '240'], + ['RPL_STATSLLINE', '241'], + ['RPL_STATSUPTIME', '242'], + ['RPL_STATSOLINE', '243'], + ['RPL_STATSHLINE', '244'], + ['RPL_STATSSLINE', '245'], + ['RPL_STATSPING', '246'], + ['RPL_STATSTLINE', '246'], + ['RPL_STATSULINE', '246'], + ['RPL_STATSBLINE', '247'], + ['RPL_STATSXLINE', '247'], + ['RPL_STATSGLINE', '247'], + ['RPL_STATSULINE', '248'], + ['RPL_STATSDEFINE', '248'], + ['RPL_STATSULINE', '249'], + ['RPL_STATSDEBUG', '249'], + ['RPL_STATSDLINE', '250'], + ['RPL_STATSCONN', '250'], + ['RPL_LUSERCLIENT', '251'], + ['RPL_LUSEROP', '252'], + ['RPL_LUSERUNKNOWN', '253'], + ['RPL_LUSERCHANNELS', '254'], + ['RPL_LUSERME', '255'], + ['RPL_ADMINME', '256'], + ['RPL_ADMINLOC1', '257'], + ['RPL_ADMINLOC2', '258'], + ['RPL_ADMINEMAIL', '259'], + ['RPL_TRACELOG', '261'], + ['RPL_TRACEPING', '262'], + ['RPL_TRACEEND', '262'], + ['RPL_TRYAGAIN', '263'], + ['RPL_LOCALUSERS', '265'], + ['RPL_GLOBALUSERS', '266'], + ['RPL_START_NETSTAT', '267'], + ['RPL_NETSTAT', '268'], + ['RPL_END_NETSTAT', '269'], + ['RPL_PRIVS', '270'], + ['RPL_SILELIST', '271'], + ['RPL_ENDOFSILELIST', '272'], + ['RPL_NOTIFY', '273'], + ['RPL_ENDNOTIFY', '274'], + ['RPL_STATSDELTA', '274'], + ['RPL_STATSDLINE', '275'], + ['RPL_VCHANEXIST', '276'], + ['RPL_VCHANHELP', '278'], + ['RPL_ENDOFGLIST', '281'], + ['RPL_ACCEPTLIST', '281'], + ['RPL_JUPELIST', '282'], + ['RPL_ALIST', '283'], + ['RPL_ENDOFALIST', '284'], + ['RPL_GLIST_HASH', '285'], + ['RPL_NEWHOSTIS', '285'], + ['RPL_CHANINFO_USERS', '286'], + ['RPL_CHKHEAD', '286'], + ['RPL_CHANINFO_CHOPS', '287'], + ['RPL_CHANUSER', '287'], + ['RPL_CHANINFO_VOICES', '288'], + ['RPL_PATCHHEAD', '288'], + ['RPL_CHANINFO_AWAY', '289'], + ['RPL_PATCHCON', '289'], + ['RPL_CHANINFO_OPERS', '290'], + ['RPL_HELPHDR', '290'], + ['RPL_DATASTR', '290'], + ['RPL_CHANINFO_BANNED', '291'], + ['RPL_HELPOP', '291'], + ['RPL_ENDOFCHECK', '291'], + ['RPL_CHANINFO_BANS', '292'], + ['RPL_HELPTLR', '292'], + ['RPL_CHANINFO_INVITE', '293'], + ['RPL_HELPHLP', '293'], + ['RPL_CHANINFO_INVITES', '294'], + ['RPL_HELPFWD', '294'], + ['RPL_CHANINFO_KICK', '295'], + ['RPL_HELPIGN', '295'], + ['RPL_CHANINFO_KICKS', '296'], + ['RPL_END_CHANINFO', '299'], + ['RPL_NONE', '300'], + ['RPL_AWAY', '301'], + ['RPL_AWAY', '301'], + ['RPL_USERHOST', '302'], + ['RPL_ISON', '303'], + ['RPL_TEXT', '304'], + ['RPL_NOWAWAY', '306'], + ['RPL_USERIP', '307'], + ['RPL_SUSERHOST', '307'], + ['RPL_NOTIFYACTION', '308'], + ['RPL_WHOISADMIN', '308'], + ['RPL_RULESSTART', '308'], + ['RPL_NICKTRACE', '309'], + ['RPL_WHOISSADMIN', '309'], + ['RPL_ENDOFRULES', '309'], + ['RPL_WHOISHELPER', '309'], + ['RPL_WHOISSVCMSG', '310'], + ['RPL_WHOISHELPOP', '310'], + ['RPL_WHOISSERVICE', '310'], + ['RPL_WHOISUSER', '311'], + ['RPL_WHOISSERVER', '312'], + ['RPL_WHOISOPERATOR', '313'], + ['RPL_WHOWASUSER', '314'], + ['RPL_ENDOFWHO', '315'], + ['RPL_WHOISCHANOP', '316'], + ['RPL_WHOISIDLE', '317'], + ['RPL_ENDOFWHOIS', '318'], + ['RPL_WHOISCHANNELS', '319'], + ['RPL_WHOISVIRT', '320'], + ['RPL_WHOIS_HIDDEN', '320'], + ['RPL_WHOISSPECIAL', '320'], + ['RPL_LISTSTART', '321'], + ['RPL_LIST', '322'], + ['RPL_LISTEND', '323'], + ['RPL_CHANNELMODEIS', '324'], + ['RPL_UNIQOPIS', '325'], + ['RPL_CHANNELPASSIS', '325'], + ['RPL_CHPASSUNKNOWN', '327'], + ['RPL_CREATIONTIME', '329'], + ['RPL_WHOWAS_TIME', '330'], + ['RPL_NOTOPIC', '331'], + ['RPL_TOPIC', '332'], + ['RPL_TOPICWHOTIME', '333'], + ['RPL_LISTUSAGE', '334'], + ['RPL_COMMANDSYNTAX', '334'], + ['RPL_LISTSYNTAX', '334'], + ['RPL_WHOISBOT', '335'], + ['RPL_CHANPASSOK', '338'], + ['RPL_BADCHANPASS', '339'], + ['RPL_INVITING', '341'], + ['RPL_SUMMONING', '342'], + ['RPL_INVITED', '345'], + ['RPL_INVITELIST', '346'], + ['RPL_ENDOFINVITELIST', '347'], + ['RPL_EXCEPTLIST', '348'], + ['RPL_ENDOFEXCEPTLIST', '349'], + ['RPL_VERSION', '351'], + ['RPL_WHOREPLY', '352'], + ['RPL_NAMREPLY', '353'], + ['RPL_WHOSPCRPL', '354'], + ['RPL_NAMREPLY_', '355'], + ['RPL_MAP', '357'], + ['RPL_MAPMORE', '358'], + ['RPL_MAPEND', '359'], + ['RPL_KILLDONE', '361'], + ['RPL_CLOSING', '362'], + ['RPL_CLOSEEND', '363'], + ['RPL_LINKS', '364'], + ['RPL_ENDOFLINKS', '365'], + ['RPL_ENDOFNAMES', '366'], + ['RPL_BANLIST', '367'], + ['RPL_ENDOFBANLIST', '368'], + ['RPL_ENDOFWHOWAS', '369'], + ['RPL_INFO', '371'], + ['RPL_MOTD', '372'], + ['RPL_INFOSTART', '373'], + ['RPL_ENDOFINFO', '374'], + ['RPL_MOTDSTART', '375'], + ['RPL_ENDOFMOTD', '376'], + ['RPL_KICKEXPIRED', '377'], + ['RPL_SPAM', '377'], + ['RPL_BANEXPIRED', '378'], + ['RPL_WHOISHOST', '378'], + ['RPL_KICKLINKED', '379'], + ['RPL_WHOISMODES', '379'], + ['RPL_BANLINKED', '380'], + ['RPL_YOURHELPER', '380'], + ['RPL_YOUREOPER', '381'], + ['RPL_REHASHING', '382'], + ['RPL_YOURESERVICE', '383'], + ['RPL_MYPORTIS', '384'], + ['RPL_NOTOPERANYMORE', '385'], + ['RPL_QLIST', '386'], + ['RPL_IRCOPS', '386'], + ['RPL_ENDOFQLIST', '387'], + ['RPL_ENDOFIRCOPS', '387'], + ['RPL_ALIST', '388'], + ['RPL_ENDOFALIST', '389'], + ['RPL_TIME', '391'], + ['RPL_TIME', '391'], + ['RPL_TIME', '391'], + ['RPL_TIME', '391'], + ['RPL_USERSSTART', '392'], + ['RPL_USERS', '393'], + ['RPL_ENDOFUSERS', '394'], + ['RPL_NOUSERS', '395'], + ['RPL_HOSTHIDDEN', '396'], + ['ERR_UNKNOWNERROR', '400'], + ['ERR_NOSUCHNICK', '401'], + ['ERR_NOSUCHSERVER', '402'], + ['ERR_NOSUCHCHANNEL', '403'], + ['ERR_CANNOTSENDTOCHAN', '404'], + ['ERR_TOOMANYCHANNELS', '405'], + ['ERR_WASNOSUCHNICK', '406'], + ['ERR_TOOMANYTARGETS', '407'], + ['ERR_NOSUCHSERVICE', '408'], + ['ERR_NOCOLORSONCHAN', '408'], + ['ERR_NOORIGIN', '409'], + ['ERR_NORECIPIENT', '411'], + ['ERR_NOTEXTTOSEND', '412'], + ['ERR_NOTOPLEVEL', '413'], + ['ERR_WILDTOPLEVEL', '414'], + ['ERR_BADMASK', '415'], + ['ERR_TOOMANYMATCHES', '416'], + ['ERR_QUERYTOOLONG', '416'], + ['ERR_LENGTHTRUNCATED', '419'], + ['ERR_UNKNOWNCOMMAND', '421'], + ['ERR_NOMOTD', '422'], + ['ERR_NOADMININFO', '423'], + ['ERR_FILEERROR', '424'], + ['ERR_NOOPERMOTD', '425'], + ['ERR_TOOMANYAWAY', '429'], + ['ERR_EVENTNICKCHANGE', '430'], + ['ERR_NONICKNAMEGIVEN', '431'], + ['ERR_ERRONEUSNICKNAME', '432'], + ['ERR_NICKNAMEINUSE', '433'], + ['ERR_SERVICENAMEINUSE', '434'], + ['ERR_NORULES', '434'], + ['ERR_SERVICECONFUSED', '435'], + ['ERR_BANONCHAN', '435'], + ['ERR_NICKCOLLISION', '436'], + ['ERR_UNAVAILRESOURCE', '437'], + ['ERR_BANNICKCHANGE', '437'], + ['ERR_NICKTOOFAST', '438'], + ['ERR_DEAD', '438'], + ['ERR_TARGETTOOFAST', '439'], + ['ERR_SERVICESDOWN', '440'], + ['ERR_USERNOTINCHANNEL', '441'], + ['ERR_NOTONCHANNEL', '442'], + ['ERR_USERONCHANNEL', '443'], + ['ERR_NOLOGIN', '444'], + ['ERR_SUMMONDISABLED', '445'], + ['ERR_USERSDISABLED', '446'], + ['ERR_NONICKCHANGE', '447'], + ['ERR_NOTIMPLEMENTED', '449'], + ['ERR_NOTREGISTERED', '451'], + ['ERR_IDCOLLISION', '452'], + ['ERR_HOSTILENAME', '455'], + ['ERR_ACCEPTFULL', '456'], + ['ERR_ACCEPTNOT', '458'], + ['ERR_NOTFORHALFOPS', '460'], + ['ERR_NEEDMOREPARAMS', '461'], + ['ERR_ALREADYREGISTERED', '462'], + ['ERR_NOPERMFORHOST', '463'], + ['ERR_PASSWDMISMATCH', '464'], + ['ERR_YOUREBANNEDCREEP', '465'], + ['ERR_YOUWILLBEBANNED', '466'], + ['ERR_KEYSET', '467'], + ['ERR_INVALIDUSERNAME', '468'], + ['ERR_ONLYSERVERSCANCHANGE', '468'], + ['ERR_LINKSET', '469'], + ['ERR_LINKCHANNEL', '470'], + ['ERR_KICKEDFROMCHAN', '470'], + ['ERR_CHANNELISFULL', '471'], + ['ERR_UNKNOWNMODE', '472'], + ['ERR_INVITEONLYCHAN', '473'], + ['ERR_BANNEDFROMCHAN', '474'], + ['ERR_BADCHANNELKEY', '475'], + ['ERR_BADCHANMASK', '476'], + ['ERR_NOCHANMODES', '477'], + ['ERR_NEEDREGGEDNICK', '477'], + ['ERR_BANLISTFULL', '478'], + ['ERR_BADCHANNAME', '479'], + ['ERR_LINKFAIL', '479'], + ['ERR_NOULINE', '480'], + ['ERR_CANNOTKNOCK', '480'], + ['ERR_NOPRIVILEGES', '481'], + ['ERR_CHANOPRIVSNEEDED', '482'], + ['ERR_CANTKILLSERVER', '483'], + ['ERR_RESTRICTED', '484'], + ['ERR_ISCHANSERVICE', '484'], + ['ERR_DESYNC', '484'], + ['ERR_ATTACKDENY', '484'], + ['ERR_UNIQOPRIVSNEEDED', '485'], + ['ERR_KILLDENY', '485'], + ['ERR_CANTKICKADMIN', '485'], + ['ERR_ISREALSERVICE', '485'], + ['ERR_NONONREG', '486'], + ['ERR_ACCOUNTONLY', '486'], + ['ERR_CHANTOORECENT', '487'], + ['ERR_MSGSERVICES', '487'], + ['ERR_TSLESSCHAN', '488'], + ['ERR_VOICENEEDED', '489'], + ['ERR_SECUREONLYCHAN', '489'], + ['ERR_NOOPERHOST', '491'], + ['ERR_NOSERVICEHOST', '492'], + ['ERR_NOFEATURE', '493'], + ['ERR_BADFEATURE', '494'], + ['ERR_BADLOGTYPE', '495'], + ['ERR_BADLOGSYS', '496'], + ['ERR_BADLOGVALUE', '497'], + ['ERR_ISOPERLCHAN', '498'], + ['ERR_CHANOWNPRIVNEEDED', '499'], + ['ERR_UMODEUNKNOWNFLAG', '501'], + ['ERR_USERSDONTMATCH', '502'], + ['ERR_GHOSTEDCLIENT', '503'], + ['ERR_VWORLDWARN', '503'], + ['ERR_USERNOTONSERV', '504'], + ['ERR_TOOMANYWATCH', '512'], + ['ERR_BADPING', '513'], + ['ERR_INVALID_ERROR', '514'], + ['ERR_TOOMANYDCC', '514'], + ['ERR_BADEXPIRE', '515'], + ['ERR_DONTCHEAT', '516'], + ['ERR_DISABLED', '517'], + ['ERR_NOINVITE', '518'], + ['ERR_LONGMASK', '518'], + ['ERR_ADMONLY', '519'], + ['ERR_TOOMANYUSERS', '519'], + ['ERR_OPERONLY', '520'], + ['ERR_MASKTOOWIDE', '520'], + ['ERR_WHOTRUNC', '520'], + ['ERR_LISTSYNTAX', '521'], + ['ERR_WHOSYNTAX', '522'], + ['ERR_WHOLIMEXCEED', '523'], + ['ERR_QUARANTINED', '524'], + ['ERR_OPERSPVERIFY', '524'], + ['ERR_REMOTEPFX', '525'], + ['ERR_PFXUNROUTABLE', '526'], + ['ERR_BADHOSTMASK', '550'], + ['ERR_HOSTUNAVAIL', '551'], + ['ERR_USINGSLINE', '552'], + ['ERR_STATSSLINE', '553'], + ['RPL_LOGON', '600'], + ['RPL_LOGOFF', '601'], + ['RPL_WATCHOFF', '602'], + ['RPL_WATCHSTAT', '603'], + ['RPL_NOWON', '604'], + ['RPL_NOWOFF', '605'], + ['RPL_WATCHLIST', '606'], + ['RPL_ENDOFWATCHLIST', '607'], + ['RPL_WATCHCLEAR', '608'], + ['RPL_MAPMORE', '610'], + ['RPL_ISOPER', '610'], + ['RPL_ISLOCOP', '611'], + ['RPL_ISNOTOPER', '612'], + ['RPL_ENDOFISOPER', '613'], + ['RPL_MAPMORE', '615'], + ['RPL_WHOISMODES', '615'], + ['RPL_WHOISHOST', '616'], + ['RPL_DCCSTATUS', '617'], + ['RPL_WHOISBOT', '617'], + ['RPL_DCCLIST', '618'], + ['RPL_ENDOFDCCLIST', '619'], + ['RPL_WHOWASHOST', '619'], + ['RPL_DCCINFO', '620'], + ['RPL_RULESSTART', '620'], + ['RPL_RULES', '621'], + ['RPL_ENDOFRULES', '622'], + ['RPL_MAPMORE', '623'], + ['RPL_OMOTDSTART', '624'], + ['RPL_OMOTD', '625'], + ['RPL_ENDOFO', '626'], + ['RPL_SETTINGS', '630'], + ['RPL_ENDOFSETTINGS', '631'], + ['RPL_DUMPING', '640'], + ['RPL_DUMPRPL', '641'], + ['RPL_EODUMP', '642'], + ['RPL_TRACEROUTE_HOP', '660'], + ['RPL_TRACEROUTE_START', '661'], + ['RPL_MODECHANGEWARN', '662'], + ['RPL_CHANREDIR', '663'], + ['RPL_SERVMODEIS', '664'], + ['RPL_OTHERUMODEIS', '665'], + ['RPL_ENDOF_GENERIC', '666'], + ['RPL_WHOWASDETAILS', '670'], + ['RPL_WHOISSECURE', '671'], + ['RPL_UNKNOWNMODES', '672'], + ['RPL_CANNOTSETMODES', '673'], + ['RPL_LUSERSTAFF', '678'], + ['RPL_TIMEONSERVERIS', '679'], + ['RPL_NETWORKS', '682'], + ['RPL_YOURLANGUAGEIS', '687'], + ['RPL_LANGUAGE', '688'], + ['RPL_WHOISSTAFF', '689'], + ['RPL_WHOISLANGUAGE', '690'], + ['RPL_MODLIST', '702'], + ['RPL_ENDOFMODLIST', '703'], + ['RPL_HELPSTART', '704'], + ['RPL_HELPTXT', '705'], + ['RPL_ENDOFHELP', '706'], + ['RPL_ETRACEFULL', '708'], + ['RPL_ETRACE', '709'], + ['RPL_KNOCK', '710'], + ['RPL_KNOCKDLVR', '711'], + ['ERR_TOOMANYKNOCK', '712'], + ['ERR_CHANOPEN', '713'], + ['ERR_KNOCKONCHAN', '714'], + ['ERR_KNOCKDISABLED', '715'], + ['RPL_TARGUMODEG', '716'], + ['RPL_TARGNOTIFY', '717'], + ['RPL_UMODEGMSG', '718'], + ['RPL_OMOTDSTART', '720'], + ['RPL_OMOTD', '721'], + ['RPL_ENDOFOMOTD', '722'], + ['ERR_NOPRIVS', '723'], + ['RPL_TESTMARK', '724'], + ['RPL_TESTLINE', '725'], + ['RPL_NOTESTLINE', '726'], + ['RPL_XINFO', '771'], + ['RPL_XINFOSTART', '773'], + ['RPL_XINFOEND', '774'], + ['RPL_LOGGEDIN', '900'], + ['RPL_LOGGEDOUT', '901'], + ['ERR_NICKLOCKED', '902'], + ['RPL_SASLSUCCESS', '903'], + ['ERR_SASLFAIL', '904'], + ['ERR_SASLTOOLONG', '905'], + ['ERR_SASLABORTED', '906'], + ['ERR_SASLALREADY', '907'], + ['RPL_SASLMECHS', '908'], + ['ERR_CANNOTDOCOMMAND', '972'], + ['ERR_CANNOTCHANGEUMODE', '973'], + ['ERR_CANNOTCHANGECHANMODE', '974'], + ['ERR_CANNOTCHANGESERVERMODE', '975'], + ['ERR_CANNOTSENDTONICK', '976'], + ['ERR_UNKNOWNSERVERMODE', '977'], + ['ERR_SERVERMODELOCK', '979'], + ['ERR_BADCHARENCODING', '980'], + ['ERR_TOOMANYLANGUAGES', '981'], + ['ERR_NOLANGUAGE', '982'], + ['ERR_TEXTTOOSHORT', '983'], + ['ERR_NUMERIC_ERR', '999'] +]); diff --git a/lib/irc/connection.js b/lib/irc/connection.js new file mode 100644 index 00000000..2b0b5144 --- /dev/null +++ b/lib/irc/connection.js @@ -0,0 +1,821 @@ +'use strict'; + +const dns = require('dns'); +const crypto = require('crypto'); +const EventEmitter = require('events'); +const os = require('os'); +const codes = require('./codes'); + +const PING_TIMEOUT = 120 * 1000; +const SOCKET_TIMEOUT = 5 * 60 * 1000; + +class IRCConnection extends EventEmitter { + constructor(server, socket) { + super(); + this.server = server; + this._socket = socket; + + this._closed = false; + this._closing = false; + + this._authenticating = false; + + this.remoteAddress = this._socket.remoteAddress; + this.id = crypto + .randomBytes(8) + .toString('base64') + .replace(/[=/+]+/g, '') + .toUpperCase(); + + this.connectionPass = false; + + this.hostname = (server.options.hostname || os.hostname() || this._socket.localAddress || 'localhost').toLowerCase().trim(); + + this.processing = false; + this.queue = []; + this._remainder = ''; + + this.starting = false; + this.started = false; + this.capStarted = false; + this.capEnded = false; + this.capEnabled = new Set(); + } + + init() { + this._setListeners(); + this._resetSession(); + this.server.logger.info( + { + tnx: 'connection', + cid: this.id, + host: this.remoteAddress + }, + 'Connection from %s', + this.remoteAddress + ); + this.send({ + verb: 'NOTICE', + target: false, + params: 'Auth', + message: '*** Looking up your hostname...' + }); + try { + dns.reverse(this.remoteAddress, (err, hostnames) => { + if (!err && hostnames && hostnames.length) { + this.session.clientHostname = hostnames[0]; + this.send({ + verb: 'NOTICE', + target: false, + params: 'Auth', + message: '*** Found your hostname' + }); + } else { + this.session.clientHostname = this.remoteAddress; + this.send({ + verb: 'NOTICE', + target: false, + params: 'Auth', + message: '*** Could not resolve your hostname; using your IP address (' + this.remoteAddress + ') instead' + }); + } + }); + } catch (E) { + this.session.clientHostname = this.remoteAddress; + this.send({ + verb: 'NOTICE', + target: false, + params: '*', + message: '*** Could not resolve your hostname; using your IP address (' + this.remoteAddress + ') instead' + }); + } + this.updatePinger(); + } + + write(payload) { + if (!this._socket || !this._socket.writable) { + return; + } + this._socket.write(payload); + } + + send(payload) { + if (!this._socket || !this._socket.writable) { + return; + } + + if (payload && typeof payload === 'object') { + payload.source = payload.source || this.hostname; + + let message = [':' + payload.source]; + + if (payload.verb) { + let cmd = (payload.verb || '') + .toString() + .toUpperCase() + .trim(); + if (codes.has(cmd)) { + cmd = codes.get(cmd); + } + message.push(cmd); + } + + if (payload.target) { + message.push(payload.target); + } else if (payload.target !== false) { + message.push(this.session.nick || this.id); + } + + if (payload.params) { + message = message.concat(payload.params || []); + } + + if (payload.message) { + message.push(':' + payload.message); + } + + payload = message.join(' '); + } + + this.server.logger.debug( + { + tnx: 'send', + cid: this.id, + host: this.remoteAddress + }, + 'S:', + (payload.length < 128 ? payload : payload.substr(0, 128) + '... +' + (payload.length - 128) + ' B').replace(/\r?\n/g, '\\n') + ); + + this.write(payload + '\r\n'); + } + + _setListeners() { + this._socket.on('close', () => this._onClose()); + this._socket.on('error', err => this._onError(err)); + this._socket.setTimeout(this.server.options.socketTimeout || SOCKET_TIMEOUT, () => this._onTimeout()); + this._socket.on('readable', () => { + if (this.processing) { + return; + } + this.processing = true; + + this.read(); + }); + } + + /** + * Fired when the socket is closed + * @event + */ + _onClose(/* hadError */) { + if (this._closed) { + return; + } + + this.queue = []; + this.processing = false; + this._remainder = ''; + + this._closed = true; + this._closing = false; + + this.server.logger.info( + { + tnx: 'close', + cid: this.id, + host: this.remoteAddress, + user: this.session.user + }, + 'Connection closed to %s', + this.remoteAddress + ); + + this.emit('close'); + } + + /** + * Fired when an error occurs with the socket + * + * @event + * @param {Error} err Error object + */ + _onError(err) { + if (err.code === 'ECONNRESET' || err.code === 'EPIPE') { + return this.close(); // mark connection as 'closing' + } + + this.server.logger.error( + { + err, + tnx: 'error', + user: this.session.user + }, + '%s', + err.message + ); + this.emit('error', err); + } + + /** + * Fired when socket timeouts. Closes connection + * + * @event + */ + _onTimeout() { + // TODO: send timeout notification + this.close(); + } + + _resetSession() { + this.session = { + state: 'AUTHORIZATION', + remoteAddress: this.remoteAddress + }; + } + + close() { + clearTimeout(this.session.pingTimer); + if (!this._socket.destroyed && this._socket.writable) { + this._socket.end(); + } + this._closing = true; + } + + read() { + // update PING timeout + this.updatePinger(); + + let chunk; + let data = this._remainder; + while ((chunk = this._socket.read()) !== null) { + data += chunk.toString('binary'); + if (data.indexOf('\n') >= 0) { + let lines = data.split(/\r?\n/).map(line => Buffer.from(line, 'binary').toString()); + this._remainder = lines.pop(); + + if (lines.length) { + if (this.queue.length) { + this.queue = this.queue.concat(lines); + } else { + this.queue = lines; + } + } + + return this.processQueue(); + } + } + + this.processing = false; + } + + processQueue() { + if (!this.queue.length) { + this.read(); // see if there's anything left to read + return; + } + let line = this.queue.shift().trim(); + if (!line) { + return this.processQueue(); + } + + let match = line.match(/^\s*(?::[^\s]+\s+)?([^\s]+)\s*/); + if (!match) { + // TODO: send error message + // Can it even happen? + return this.processQueue(); + } + + let verb = (match[1] || '').toString().toUpperCase(); + let params = line.substr(match[0].length); + let data; + let separatorPos = params.indexOf(' :'); + if (separatorPos >= 0) { + data = params.substr(separatorPos + 2); + params = params.substr(0, separatorPos); + } + params = params + .trim() + .split(/\s+/) + .filter(arg => arg); + if (data) { + params.push(data); + } + + let logLine = (line || '').toString(); + + this.server.logger.debug( + { + tnx: 'receive', + cid: this.id, + user: this.session.user + }, + 'C:', + logLine + ); + + if (typeof this['command_' + verb] === 'function') { + this['command_' + verb](params, () => { + this.processQueue(); + }); + } else { + if (this.session.user) { + this.send({ + verb: 'ERR_UNKNOWNCOMMAND', + target: this.session.nick, + params: verb, + message: 'Unknown command' + }); + } + return this.processQueue(); + } + } + + getFormattedName(skipNick) { + return (!skipNick && this.session.nick ? this.session.nick + '!' : '') + (this.session.user || 'unknown') + '@' + this.session.clientHostname; + } + + checkSessionStart() { + if (this.starting || this.started) { + return; + } + if (!this.session.user || !this.session.nick || (this.capStarted && !this.capEnded)) { + return; + } + this.starting = true; + + this.server.logger.info( + { + tnx: 'session', + cid: this.id, + host: this.remoteAddress, + user: this.session.user, + name: this.session.name + }, + 'Registered %s as %s', + this.session.user, + JSON.stringify(this.session.name) + ); + + this.send({ verb: 'NOTICE', target: 'Auth', message: 'Welcome to \x02' + this.server.name + '\x02!' }); + + this.send({ verb: 'RPL_WELCOME', message: 'Welcome to the ' + this.server.name + ' IRC Network ' + this.getFormattedName() }); + + this.send({ verb: 'RPL_YOURHOST', message: 'Your host is ' + this.hostname + ', running version ' + this.server.version }); + + this.send({ verb: 'RPL_CREATED', message: 'This server was created ' + this.server.startTimeFormatted }); + + // TODO: use real flags, [ + this.send({ verb: 'RPL_MYINFO', params: [this.hostname, this.server.version, 'iosw', 'biklmnopstv', 'bklov'] }); + + this.send({ verb: 'RPL_ISUPPORT', params: this.server.supportedFormatted, message: 'are supported by this server' }); + + this.send({ verb: 'RPL_YOURID', params: this.id, message: 'your unique ID' }); + + this.send({ verb: 'RPL_MOTDSTART', message: this.hostname + ' message of the day' }); + + this.server.motd.split(/\r?\n/).forEach(line => this.send({ verb: 'RPL_MOTD', message: '- ' + line })); + + this.send({ verb: 'RPL_ENDOFMOTD', message: 'End of message of the day' }); + + if (!this.session.auth) { + this.send({ + source: ':NickServ!NickServ@services.', + verb: 'NOTICE', + message: 'This server requires all users to be authenticated. Identify via /msg NickServ identify ' + }); + } + + this.starting = false; + this.started = true; + } + + authenticate(user, password, next) { + this.server.userHandler.authenticate( + user.replace(/\+/, '@'), + password, + 'irc', + { + protocol: 'IRC', + ip: this.remoteAddress + }, + (err, result) => { + if (err) { + return next(err); + } + if (!result) { + return next(); + } + + if (result.scope === 'master' && result.require2fa) { + // master password not allowed if 2fa is enabled! + return next(); + } + + this.session.user = result.username; + + next(null, { + id: result.user, + username: result.username + }); + } + ); + } + + checkAuth() { + if (!this.session.auth) { + this.send({ verb: 'ERR_NOTREGISTERED', params: 'PRIVMSG', message: 'Authentication required to chat in this server' }); + return false; + } + return true; + } + + updatePinger() { + clearTimeout(this.session.pingTimer); + this.session.pingTimer = setTimeout(() => { + this.send('PING :' + this.hostname); + this.session.pingTimer = setTimeout(() => { + this.send( + 'ERROR :Closing link: (' + + (this.session.user || 'unknown') + + '@' + + this.session.clientHostname + + ') [Ping timeout: ' + + Math.round(PING_TIMEOUT / 1000) + + ' seconds]' + ); + this.server.quit(this, 'Ping timeout: ' + Math.round(PING_TIMEOUT / 1000) + ' seconds'); + this.close(); + }, PING_TIMEOUT); + }, PING_TIMEOUT); + } + + command_QUIT() { + this.send('ERROR :Closing link: (' + this.getFormattedName(true) + ') [Client exited]'); + this.server.quit(this, 'Client exited'); + this.close(); + } + + command_PING(params, next) { + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'PING', message: 'Not enough parameters' }); + return next(); + } + if (!this.session.user) { + this.send({ verb: 'ERR_NOTREGISTERED', params: 'PING', message: 'You have not registered' }); + return next(); + } + let host = params[0] || this.session.clientHostname; + this.send({ verb: 'PONG', target: this.hostname, message: host }); + return next(); + } + + command_PONG(params, next) { + return next(); + } + + command_NICK(params, next) { + if (params.length > 1) { + this.send({ verb: 'ERR_ERRONEUSNICKNAME', params, message: 'Erroneous Nickname' }); + } else if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params, message: 'Not enough parameters' }); + } else if (this.server.disabledNicks.includes(params[0].trim().toLowerCase())) { + this.send({ verb: 'ERR_ERRONEUSNICKNAME', params, message: 'Erroneous Nickname' }); + } else { + this.session.nick = params[0]; + } + this.checkSessionStart(); + this.server.nick(this); + return next(); + } + + command_PASS(params, next) { + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'PASS', message: 'Not enough parameters' }); + return next(); + } + + let pass = params.join(' '); + + this.connectionPass = pass; + + return next(); + } + + command_USER(params, next) { + if (this.session.user) { + this.send({ verb: 'ERR_ALREADYREGISTERED', message: 'You may not reregister' }); + return next(); + } + + if (params.length < 4) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'USER', message: 'Not enough parameters' }); + return next(); + } + + this.session.user = params[0]; + this.session.name = params.slice(3).join(' '); + this.session.time = new Date(); + + if (this.connectionPass) { + this.authenticate(this.session.user, this.connectionPass, (err, auth) => { + if (err) { + this.server.quit(this, 'User registration failed. ' + err.message); + return this.close(); + } + if (auth) { + this.session.auth = auth; + this.checkSessionStart(); + } + return next(); + }); + } else { + this.checkSessionStart(); + return next(); + } + } + + command_JOIN(params, next) { + if (!this.session.user || !this.session.nick) { + this.send({ verb: 'ERR_NOTREGISTERED', params: 'JOIN', message: 'You have not registered' }); + return next(); + } + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'JOIN', message: 'Not enough parameters' }); + return next(); + } + + let channel = params[0].trim(); + if (channel.length < 2 || !/^[#&]/.test(channel) || /[#&\s]/.test(channel.substr(1))) { + this.send({ verb: 'ERR_NOSUCHCHANNEL', params: channel, message: 'Invalid channel name' }); + return next(); + } + + if (!this.checkAuth()) { + return next(); + } + + this.server.join(channel, this); + + return next(); + } + + command_PART(params, next) { + if (!this.session.user || !this.session.nick) { + this.send({ verb: 'ERR_NOTREGISTERED', params: 'JOIN', message: 'You have not registered' }); + return next(); + } + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'JOIN', message: 'Not enough parameters' }); + return next(); + } + + let channel = params[0].trim(); + if (channel.length < 2 || !/^[#&]/.test(channel) || /[#&\s]/.test(channel.substr(1))) { + this.send({ verb: 'ERR_NOSUCHCHANNEL', params: channel, message: 'No such channel' }); + return next(); + } + + if (!this.checkAuth()) { + return next(); + } + + this.server.leave(channel, this, params.slice(1).join(' ')); + + return next(); + } + + command_PRIVMSG(params, next) { + if (!this.session.user || !this.session.nick) { + this.send({ verb: 'ERR_NOTREGISTERED', params: 'PRIVMSG', message: 'You have not registered' }); + return next(); + } + + if (params.length < 2) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'PRIVMSG', message: 'Not enough parameters' }); + return next(); + } + + let target = params[0].trim(); + if (/[#&\s]/.test(target.substr(1))) { + this.send({ verb: 'ERR_NOSUCHNICK', params: target, message: 'No such nick/channel' }); + return next(); + } + + if (target.trim().toLowerCase() === 'nickserv') { + return this.command_NICKSERV( + params + .slice(1) + .join(' ') + .split(/\s+/) + .filter(arg => arg), + next + ); + } + + if (!this.checkAuth()) { + return next(); + } + + this.server.send(target, this, params.slice(1).join(' ')); + + return next(); + } + + command_CAP(params, next) { + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'CAP', message: 'Not enough parameters' }); + return next(); + } + + let allowed = ['sasl']; + + this.capStarted = true; + let subcommand = params + .shift() + .toUpperCase() + .trim(); + + switch (subcommand) { + case 'LS': + this.send({ verb: 'CAP', target: this.session.nick || '*', params: 'LS', message: allowed.join(' ') }); + break; + + case 'LIST': + this.send({ verb: 'CAP', target: this.session.nick || '*', params: 'LIST', message: Array.from(this.capEnabled).join(' ') }); + break; + + case 'REQ': + { + let ok = true; + let enable = []; + let disable = []; + params.forEach(arg => { + let argName = arg.trim().toLowerCase(); + switch (argName.charAt(0)) { + case '-': + disable = true; + argName = argName.substr(1); + disable.push(argName); + break; + case '+': + argName = argName.substr(1); + enable.push(argName); + break; + default: + enable.push(argName); + } + if (!allowed.includes(argName)) { + // unknown extension + ok = false; + } + }); + + if (ok) { + // apply changes + enable.forEach(arg => this.capEnabled.add(arg)); + disable.forEach(arg => this.capEnabled.delete(arg)); + } + + this.send({ verb: 'CAP', target: this.session.nick || '*', params: ok ? 'ACK' : 'NAK', message: params.join(' ') }); + } + break; + + case 'END': + this.capEnded = true; + if (this._authenticating) { + this._authenticating = false; + this.send({ verb: 'ERR_SASLABORTED', target: this.session.nick || '*', message: 'SASL authentication aborted' }); + } + + this.checkSessionStart(); + break; + } + next(); + } + + command_NICKSERV(params, next) { + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'CAP', message: 'Not enough parameters' }); + return next(); + } + + if (this.session.auth) { + this.send({ + source: ':NickServ!NickServ@services.', + verb: 'NOTICE', + target: this.session.nick, + message: 'Already identified as ' + this.session.user + }); + return next(); + } + + this.capStarted = true; + let subcommand = params + .shift() + .toUpperCase() + .trim(); + + switch (subcommand) { + case 'IDENTIFY': { + return this.authenticate(this.session.user, params.join(' '), (err, auth) => { + if (err) { + this.server.quit(this, 'User registration failed. ' + err.message); + return this.close(); + } + if (auth) { + this.session.auth = auth; + this.send({ + source: ':NickServ!NickServ@services.', + verb: 'NOTICE', + target: this.session.nick, + message: 'You are now identified for ' + this.session.user + }); + } else { + this.send({ + source: ':NickServ!NickServ@services.', + verb: 'NOTICE', + target: this.session.nick, + message: 'Invalid password for ' + this.session.user + }); + } + return next(); + }); + } + } + next(); + } + + command_AUTHENTICATE(params, next) { + if (!params.length) { + this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'AUTHENTICATE', message: 'Not enough parameters' }); + return next(); + } + + if (!this.capEnabled.has('sasl')) { + // Authentication not enabled + return next(); + } + + if (this.session.auth) { + this.send({ verb: 'ERR_SASLALREADY', target: this.session.nick || '*', params: 'AUTHENTICATE', message: 'You have already authenticated' }); + return next(); + } + + if (!this._authenticating) { + switch (params[0].trim().toUpperCase()) { + case 'PLAIN': + this.send('AUTHENTICATE +'); + this._authenticating = true; + return next(); + + default: + this.send({ verb: 'RPL_SASLMECHS', target: this.session.nick || '*', params: 'PLAIN', message: 'are the available SASL mechanisms' }); + this.send({ verb: 'ERR_SASLFAIL', target: this.session.nick || '*', message: 'SASL authentication failed' }); + return next(); + } + } + + let auth = params[0].trim(); + if (auth === '*') { + this._authenticating = false; + this.send({ verb: 'ERR_SASLABORTED', target: this.session.nick || '*', message: 'SASL authentication aborted' }); + return next(); + } + + let parts = Buffer.from(auth, 'base64') + .toString() + .split('\x00'); + + //let nick = parts[0] || this.session.nick; + let user = parts[1] || this.session.nick; + let password = parts[2] || ''; + + this.authenticate(user, password, (err, auth) => { + this._authenticating = false; + if (err) { + this.server.quit(this, 'User registration failed. ' + err.message); + return this.close(); + } + if (auth) { + this.session.auth = auth; + this.send({ + verb: 'RPL_LOGGEDIN', + target: this.session.nick || '*', + params: [this.getFormattedName(), this.session.user], + message: 'You are now logged in as ' + this.session.user + }); + this.send({ verb: 'RPL_SASLSUCCESS', target: this.session.nick || '*', message: 'SASL authentication successful' }); + } else { + this.send({ verb: 'ERR_SASLFAIL', target: this.session.nick || '*', message: 'SASL authentication failed' }); + } + return next(); + }); + + this._authenticating = false; + + return next(); + } +} + +module.exports = IRCConnection; diff --git a/lib/irc/server.js b/lib/irc/server.js new file mode 100644 index 00000000..f8588fc9 --- /dev/null +++ b/lib/irc/server.js @@ -0,0 +1,303 @@ +'use strict'; + +const EventEmitter = require('events'); +const net = require('net'); +const tls = require('tls'); +const packageData = require('../../package.json'); +const tlsOptions = require('../../imap-core/lib/tls-options'); +const shared = require('nodemailer/lib/shared'); +const IRCConnection = require('./connection'); + +const CLOSE_TIMEOUT = 1 * 1000; // how much to wait until pending connections are terminated + +class IRCServer extends EventEmitter { + constructor(options) { + super(); + + this.version = 'WildDuck-v' + packageData.version; + + this.startTime = new Date(); + let dateparts = this.startTime.toUTCString().split(/[\s,]+/); + dateparts.splice(1, 0, dateparts[2]); + dateparts.splice(3, 1); + dateparts.splice(4, 0, 'at'); + this.startTimeFormatted = dateparts.join(' '); + + this.options = options || {}; + + this.name = this.options.name || 'Localnet'; + + this.motd = 'Wild Duck IRC'; // is changed later + + this.messageHandler = false; // is set later + this.userHandler = false; // is set later + + this.disabledNicks = ['admin', 'root', 'nickserv']; + + this.supported = { + CASEMAPPING: 'rfc7613', + CHANTYPES: '#&', + NETWORK: this.name, + FNC: true + }; + + this.supportedFormatted = Object.keys(this.supported).map(key => key.toUpperCase() + (this.supported[key] === true ? '' : '=' + this.supported[key])); + + this._channels = new Map(); + this._clients = new Map(); + this._nicks = new Map(); + + /** + * Timeout after close has been called until pending connections are forcibly closed + */ + this._closeTimeout = false; + + /** + * A set of all currently open connections + */ + this.connections = new Set(); + + // apply TLS defaults if needed + if (this.options.secure) { + this.options = tlsOptions(this.options); + } + + this.logger = shared.getLogger(this.options, { + component: this.options.component || 'irc-server' + }); + + this.server = (this.options.secure ? tls : net).createServer(this.options, socket => this._onConnect(socket)); + + this._setListeners(); + } + + _setListeners() { + this.server.on('listening', () => this._onListening()); + this.server.on('close', () => this._onClose()); + this.server.on('error', err => this._onError(err)); + } + + /** + * Called when server started listening + * + * @event + */ + _onListening() { + let address = this.server.address(); + this.logger.info( + // + { + tnx: 'listen', + host: address.address, + port: address.port, + secure: !!this.options.secure, + protocol: 'IRC' + }, + '%s%s Server listening on %s:%s', + this.options.secure ? 'Secure ' : '', + 'IRC', + address.family === 'IPv4' ? address.address : '[' + address.address + ']', + address.port + ); + } + + /** + * Called when server is closed + * + * @event + */ + _onClose() { + this.logger.info( + { + tnx: 'closed' + }, + 'IRC Server closed' + ); + this.emit('close'); + } + + /** + * Called when an error occurs with the server + * + * @event + */ + _onError(err) { + this.emit('error', err); + } + + _onConnect(socket) { + let connection = new IRCConnection(this, socket); + this.connections.add(connection); + connection.once('error', err => { + this.connections.delete(connection); + this._onError(err); + }); + connection.once('close', () => { + this.connections.delete(connection); + }); + connection.init(); + } + + close(callback) { + let connections = this.connections.size; + let timeout = this.options.closeTimeout || CLOSE_TIMEOUT; + + // stop accepting new connections + this.server.close(() => { + clearTimeout(this._closeTimeout); + if (typeof callback === 'function') { + return callback(); + } + }); + + // close active connections + if (connections) { + this.logger.info( + { + tnx: 'close' + }, + 'Server closing with %s pending connection%s, waiting %s seconds before terminating', + connections, + connections !== 1 ? 's' : '', + timeout / 1000 + ); + } + + this._closeTimeout = setTimeout(() => { + connections = this.connections.size; + if (connections) { + this.logger.info( + { + tnx: 'close' + }, + 'Closing %s pending connection%s to close the server', + connections, + connections !== 1 ? 's' : '' + ); + + this.connections.forEach(connection => { + connection.close(); + }); + } + }, timeout); + } + + listen(...args) { + this.server.listen(...args); + } + + join(name, client) { + let nameLC = name.toLowerCase(); + if (!this._channels.has(nameLC)) { + this._channels.set(nameLC, { + name, + topic: 'unset', + clients: new Set() + }); + } + let channel = this._channels.get(nameLC); + if (!channel.clients.has(client)) { + channel.clients.add(client); + let clientName = client.getFormattedName(); + let names = []; + channel.clients.forEach(c => { + c.send({ source: clientName, verb: 'JOIN', target: false, message: name }); + names.push(c.session.nick); + }); + client.send({ verb: 'RPL_NAMREPLY', params: ['=', name], message: names.join(' ') }); + client.send({ verb: 'RPL_ENDOFNAMES', params: name, message: 'End of /NAMES list' }); + if (!this._clients.get(client)) { + this._clients.set(client, { nick: client.session.nick, channels: new Set([channel]) }); + } else { + this._clients.get(client).channels.add(channel); + } + } + } + + quit(client, message) { + if (!this._clients.has(client)) { + return; + } + + let clientName = client.getFormattedName(); + this._clients.get(client).channels.forEach(channel => { + if (channel.clients.has(client)) { + channel.clients.forEach(c => { + c.send({ source: clientName, verb: 'QUIT', target: false, message }); + }); + channel.clients.delete(client); + } + }); + + this._clients.delete(client); + this._nicks.delete(client.session.nick); + } + + leave(name, client, message) { + let nameLC = name.toLowerCase(); + if (!this._channels.has(nameLC)) { + client.send({ verb: 'ERR_NOSUCHNICK', params: name, message: 'No such channel' }); + return; + } + let channel = this._channels.get(nameLC); + if (channel.clients.has(client)) { + let clientName = client.getFormattedName(); + channel.clients.forEach(c => { + c.send({ source: clientName, verb: 'PART', target: false, params: channel.name, message }); + }); + channel.clients.delete(client); + this._clients.get(client).channels.delete(channel); + } + } + + nick(client) { + if (!this._clients.has(client)) { + this._clients.set(client, { nick: client.session.nick, channels: new Set() }); + this._nicks.set(client.session.nick.toLowerCase(), client); + return; + } + let entry = this._clients.get(client); + if (entry.nick !== client.session.nick) { + let updated = new WeakSet(); + let clientName = entry.nick + '!' + client.getFormattedName(true); + entry.channels.forEach(channel => { + channel.clients.forEach(c => { + if (updated.has(c)) { + return; + } + updated.add(c); + c.send({ source: clientName, verb: 'NICK', target: false, params: client.session.nick }); + }); + }); + this._nicks.delete(entry.nick.toLowerCase()); + } + + entry.nick = client.session.nick; + this._nicks.set(client.session.nick.toLowerCase(), client); + } + + send(target, client, message) { + let nameLC = target.toLowerCase(); + let clientName = client.getFormattedName(); + if (/^[#&]/.test(target)) { + if (!this._channels.has(nameLC)) { + client.send({ verb: 'ERR_NOSUCHNICK', params: target, message: 'No such nick/channel' }); + return; + } + let channel = this._channels.get(nameLC); + channel.clients.forEach(c => { + if (c !== client) { + c.send({ source: clientName, target, verb: 'PRIVMSG', message }); + } + }); + } else if (this._nicks.has(nameLC)) { + let client = this._nicks.get(nameLC); + client.send({ source: clientName, verb: 'PRIVMSG', message }); + } else { + client.send({ verb: 'ERR_NOSUCHNICK', params: target, message: 'No such nick/channel' }); + return; + } + } +} + +module.exports = IRCServer; diff --git a/lib/pop3-connection.js b/lib/pop3/connection.js similarity index 99% rename from lib/pop3-connection.js rename to lib/pop3/connection.js index 41ab2914..f7a65d75 100644 --- a/lib/pop3-connection.js +++ b/lib/pop3/connection.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const EventEmitter = require('events'); -const packageData = require('../package.json'); +const packageData = require('../../package.json'); const DataStream = require('nodemailer/lib/smtp-connection/data-stream'); const SOCKET_TIMEOUT = 60 * 1000; @@ -756,7 +756,9 @@ class POP3Connection extends EventEmitter { return next(); } - let credentials = Buffer.from(plain, 'base64').toString().split('\x00'); + let credentials = Buffer.from(plain, 'base64') + .toString() + .split('\x00'); if (credentials.length !== 3) { this.send('-ERR malformed command'); return next(); diff --git a/lib/pop3-server.js b/lib/pop3/server.js similarity index 98% rename from lib/pop3-server.js rename to lib/pop3/server.js index 7843fb28..883afbcb 100644 --- a/lib/pop3-server.js +++ b/lib/pop3/server.js @@ -3,9 +3,9 @@ const EventEmitter = require('events'); const net = require('net'); const tls = require('tls'); -const tlsOptions = require('../imap-core/lib/tls-options'); +const tlsOptions = require('../../imap-core/lib/tls-options'); const shared = require('nodemailer/lib/shared'); -const POP3Connection = require('./pop3-connection'); +const POP3Connection = require('./connection'); const CLOSE_TIMEOUT = 1 * 1000; // how much to wait until pending connections are terminated diff --git a/lib/user-handler.js b/lib/user-handler.js index 52298e75..f85e3f91 100644 --- a/lib/user-handler.js +++ b/lib/user-handler.js @@ -347,7 +347,7 @@ class UserHandler { .update(password.substr(0, 4)) .digest('hex'); - let allowedScopes = ['imap', 'pop3', 'smtp']; + let allowedScopes = ['imap', 'pop3', 'smtp', 'irc']; let hasAllScopes = false; let scopeSet = new Set(); let scopes = [].concat(data.scopes || []); diff --git a/pop3.js b/pop3.js index bb5dc4c3..2b685846 100644 --- a/pop3.js +++ b/pop3.js @@ -2,7 +2,7 @@ const config = require('wild-config'); const log = require('npmlog'); -const POP3Server = require('./lib/pop3-server'); +const POP3Server = require('./lib/pop3/server'); const UserHandler = require('./lib/user-handler'); const MessageHandler = require('./lib/message-handler'); const ObjectID = require('mongodb').ObjectID; diff --git a/worker.js b/worker.js index 7684808c..b85be7ac 100644 --- a/worker.js +++ b/worker.js @@ -4,6 +4,7 @@ const config = require('wild-config'); const log = require('npmlog'); const imap = require('./imap'); const pop3 = require('./pop3'); +const irc = require('./irc'); const lmtp = require('./lmtp'); const api = require('./api'); const db = require('./lib/db'); @@ -49,29 +50,38 @@ db.connect(err => { return setTimeout(() => process.exit(1), 3000); } - log.info('App', 'All servers started, ready to process some mail'); + // Start IRC server + irc(err => { + if (err) { + log.error('App', 'Failed to start IRC server'); + errors.notify(err); + return setTimeout(() => process.exit(1), 3000); + } - // downgrade user and group if needed - if (config.group) { - try { - process.setgid(config.group); - log.info('App', 'Changed group to "%s" (%s)', config.group, process.getgid()); - } catch (E) { - log.error('App', 'Failed to change group to "%s" (%s)', config.group, E.message); - errors.notify(E); - return setTimeout(() => process.exit(1), 3000); + log.info('App', 'All servers started, ready to process some mail'); + + // downgrade user and group if needed + if (config.group) { + try { + process.setgid(config.group); + log.info('App', 'Changed group to "%s" (%s)', config.group, process.getgid()); + } catch (E) { + log.error('App', 'Failed to change group to "%s" (%s)', config.group, E.message); + errors.notify(E); + return setTimeout(() => process.exit(1), 3000); + } } - } - if (config.user) { - try { - process.setuid(config.user); - log.info('App', 'Changed user to "%s" (%s)', config.user, process.getuid()); - } catch (E) { - log.error('App', 'Failed to change user to "%s" (%s)', config.user, E.message); - errors.notify(E); - return setTimeout(() => process.exit(1), 3000); + if (config.user) { + try { + process.setuid(config.user); + log.info('App', 'Changed user to "%s" (%s)', config.user, process.getuid()); + } catch (E) { + log.error('App', 'Failed to change user to "%s" (%s)', config.user, E.message); + errors.notify(E); + return setTimeout(() => process.exit(1), 3000); + } } - } + }); }); }); });