diff --git a/README.md b/README.md index 3a5a34a..5254911 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,26 @@ ircd-js A simple IRCd project to provide an IRCd. + +Installing dependencies +=== + +```bash +# Installing docker on (latest) Ubuntu +sudo apt-get install docker.io +``` + Running === +Staring a NodeJS container: + +```bash +docker run -v $PWD:/app --workdir /app -p 127.0.0.1:6667:6667 --rm -it node:10 /bin/bash +``` + +Commands to run inside the container: + ```bash # Install all dev dependencies npm install @@ -14,5 +31,5 @@ npm install npm test # Start the index.js -npm start +node index.js ``` diff --git a/package.json b/package.json index d99e9a0..fb1e22e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ircd", - "version": "0.1.0", + "version": "0.2.0", "description": "A project implementation of the IRC protocol", "main": "index.js", "scripts": { diff --git a/src/channel.js b/src/channel.js new file mode 100644 index 0000000..b6cb5ee --- /dev/null +++ b/src/channel.js @@ -0,0 +1,32 @@ +function Channel(name) { + this.name = name + this.userlist = [] + + this.join = function(user) { + let channel = this + this.userlist.push(user) + this.userlist.forEach(function(item) { + item.sendRaw(`:${user.nickname} JOIN ${channel.name}`) + }) + } + + this.part = function(user) { + let channel = this + let userIndex = this.userlist.indexOf(user) + if (userIndex >= 0) { + this.userlist.forEach(function(item) { + item.sendRaw(`:${user.nickname} PART ${channel.name}`) + }) + this.userlist.splice(index, 1) + } + } + + this.sendMsg = function(from, message) { + const channel = this + this.userlist.forEach(function(item) { + item.sendMsg(from, message, channel.name) + }) + } +} + +module.exports = Channel diff --git a/src/server.js b/src/server.js index f6da4e4..c58009e 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,6 @@ const net = require("net") const User = require("../src/user.js") +const Channel = require("../src/channel.js") const RPL_WELCOME = '001' const ERR_NOSUCHNICK = '401' @@ -11,79 +12,111 @@ const ERR_NONICKNAMEGIVEN = '431' const ERR_NICKNAMEINUSE = '433' const ERR_NEEDMOREPARAMS = '461' + let server = {} -server.create = function create() { +server.create = function create(config = {}) { + let password + if (config.password) { + password = config.password + } let userlist = {} + let channellist = {} let server = net.createServer((socket) => { - const user = new User(socket) + const user = new User(socket, !config.password) socket.on('data', function (data) { data.toString("ascii").split("\r\n").forEach(function (commandline) { - if (commandline == "") { - return - } - let splitted = commandline.split(" ") - let tokenized = [] - let lastParam = -1 - for (let i = 0; i < splitted.length; i++) { - if (lastParam > 0) { - tokenized[lastParam] = tokenized[lastParam] + " " + splitted[i] - } else if (splitted[i].charAt(0) == ":" && i > 0) { - lastParam = i - tokenized[lastParam] = splitted[i].slice(1) - } else { - tokenized[i] = splitted[i] - } - } - let command = tokenized[0].toUpperCase() - switch (command) { - case "PING": - if (tokenized[1] && tokenized[1] === "irc.example.com") { - socket.write("PONG irc.example.com\r\n") - } - break; - case "NICK": - if (tokenized[1]) { - let nickname = tokenized[1] - // nick collision test - if (!Object.keys(userlist).includes(nickname)) { - user.setNickname(nickname) - userlist[user.nickname] = user - } else { - socket.write(ERR_NICKNAMEINUSE, " nickname in use") - } - - - } else { - socket.write(ERR_NONICKNAMEGIVEN, "ERROR: NO NICKNAME PROVIDED") - } - - break; - - case "USER": - // ignoring servername and hostname, not useful these days - user.register(tokenized[1], tokenized[4]) - let address = user.getAddress() - socket.write(`:irc.example.com 001 ${user.nickname} :Welcome to the example IRC Project ${user.nickname}!~${user.username}@${address}\r\n`, "ascii") - break; - case "PRIVMSG": - let target = userlist[tokenized[1]] - let message = tokenized[2] - - target.sendMsg(user, message) - break; - case "QUIT": - server.closeConnection(user.nickname) - break; - case "PASS": - if (tokenized[1]) - user.setPassword(tokenized[1]) - break; - - default: - console.error(`Unknown command: ${command}`); - } - }); + if (commandline == "") { + return + } + let splitted = commandline.split(" ") + let tokenized = [] + let lastParam = -1 + for (let i = 0; i < splitted.length; i++) { + if (lastParam > 0) { + tokenized[lastParam] = tokenized[lastParam] + " " + splitted[i] + } else if (splitted[i].charAt(0) == ":" && i > 0) { + lastParam = i + tokenized[lastParam] = splitted[i].slice(1) + } else { + tokenized[i] = splitted[i] + } + } + let command = tokenized[0].toUpperCase() + switch (command) { + case "PING": + if (tokenized[1] && tokenized[1] === "irc.example.com") { + socket.write("PONG irc.example.com\r\n") + } + break; + case "NICK": + if (tokenized[1]) { + let nickname = tokenized[1] + // nick collision test + if (!Object.keys(userlist).includes(nickname)) { + user.setNickname(nickname) + userlist[user.nickname] = user + } else { + socket.write(`:irc.example.com ${ERR_NICKNAMEINUSE} ${nickname} :Nickname is already in use`, 'ascii') + } + + + } else { + socket.write(`:irc.example.com ${ERR_NONICKNAMEGIVEN} :No nickname given`, 'ascii') + } + + break; + + case "USER": + // ignoring servername and hostname, not useful these days + user.register(tokenized[1], tokenized[4]) + let address = user.getAddress() + socket.write(`:irc.example.com 001 ${user.nickname} :Welcome to the example IRC Project ${user.nickname}!~${user.username}@${address}\r\n`, "ascii") + break; + case "PRIVMSG": + let target + let message = tokenized[2] + if (typeof tokenized[1] === 'string' && tokenized[1].charAt(0) === "#") { + target = channellist[tokenized[1]] + } else { + let targetUser = userlist[tokenized[1]] + if (targetUser && targetUser.registered) { + target = targetUser + } + } + if (target) { + target.sendMsg(user, message) + } else { + user.sendRaw(`:irc.example.com ${ERR_NOSUCHNICK} ${target} no such nick/channel`) + } + + break; + case "JOIN": + let channelname = tokenized[1] + if (Object.keys(channellist).includes(channelname)) { + channellist[channelname].join(user) + } else { + let channel = new Channel(channelname) + channel.join(user) + channellist[channelname] = channel + } + + break; + case "QUIT": + server.closeConnection(user.nickname) + break; + case "PASS": + if (tokenized[1] === config.password) { + user.authenticated = true + } else { + user.closeConnection() + } + break; + + default: + console.error(`Unknown command: ${command}`); + } + }); }).on('error', (err) => { console.error(err); }) diff --git a/src/user.js b/src/user.js index f207aef..804f5bf 100644 --- a/src/user.js +++ b/src/user.js @@ -1,22 +1,26 @@ -function User(socket) { +function User(socket, authenticatedDefault) { this.registered = false - this.nickname = "" + this.authenticated = authenticatedDefault + this.nickname = undefined this.connection = socket this.realname = "" this.username = "" this.setNickname = function(nickname) { + if (this.nickname !== undefined) { + this.sendRaw(`:${this.nickname} NICK ${nickname}`) + } this.nickname = nickname } this.getNickname = function(nickname) { return this.nickname } - this.setPassword = function (password) { - this.password = password - } this.register = function(username, realname) { + if (!this.authenticated) { + this.connection.destroy() + } this.username = username this.realname = realname this.registered = true @@ -25,20 +29,17 @@ function User(socket) { this.getAddress = function() { return this.connection.address().address } - this.setNickname = function (nickname) { - this.nickname = nickname - } - this.setRealName = function (realname) { - this.realname = realname - } this.closeConnection = function () { - socket.destroy() + this.connection.destroy() } - this.sendMsg = function (from, message) { - this.connection.write(`:${from.nickname} PRIVMSG ${this.nickname} :${message}\r\n`, "ascii") + this.sendMsg = function (from, message, to = this.nickname) { + this.sendRaw(`:${from.nickname} PRIVMSG ${to} :${message}`, "ascii") + } + this.sendRaw = function(message) { + this.connection.write(`${message}\r\n`, "ascii") } } diff --git a/test/channel.js b/test/channel.js new file mode 100644 index 0000000..870a640 --- /dev/null +++ b/test/channel.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const Channel = require("../src/channel.js"); + +describe('Channel', function () { + describe('#contructor()', function () { + it("should create a channel", function () { + const channel = new Channel("#testchan") + assert.equal(typeof channel, 'object') + }) + }) + + describe('#join(user)', function () { + it("should add the user to a channel and send a JOIN command to the user", function (done) { + const channel = new Channel("#testchan") + let mockedUser = {nickname: "some_nick"} + mockedUser.sendRaw = function(data) { + assert.equal(data, ":some_nick JOIN #testchan") + done() + } + channel.join(mockedUser) + }) + }) + + describe('#part(user)', function () { + it("should remove a user from a channel and send a PART command to the user", function (done) { + const channel = new Channel("#testchan") + let mockedUser = {nickname: "some_nick"} + mockedUser.sendRaw = function(data) { + // we expect a PART message from our user to the channel (including us) + if (data.indexOf("PART") >= 0) { + assert.equal(data, ":some_nick PART #testchan") + done() + } + } + // we can't part a channel without joining it first + channel.join(mockedUser) + + channel.part(mockedUser) + }) + }) + + describe('#sendMsg(from, message)', function () { + it("should remove a user from a channel and send a PART command to the user", function (done) { + const channel = new Channel("#testchan") + let mockedUser = {nickname: "some_nick", sendRaw: function() {}} + mockedUser.sendMsg = function(from, message, to) { + assert.equal(from, mockedUser) + assert.equal(message, "test message") + assert.equal(to, "#testchan") + done() + } + // we can't part a channel without joining it first + channel.join(mockedUser) + + channel.sendMsg(mockedUser, "test message") + }) + }) +}) diff --git a/test/command_join.js b/test/command_join.js new file mode 100644 index 0000000..6ea8ac3 --- /dev/null +++ b/test/command_join.js @@ -0,0 +1,72 @@ +const assert = require('assert'); +const EventEmitter = require('events'); +const IRCServer = require("../src/server.js"); + +describe("JOIN OK", function () { + it("should handle a JOIN command -> JOIN #testchan", function (done) { + const server = IRCServer.create() + let mockedSock = new EventEmitter() + mockedSock.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock.write = function (data) { + let answer = data.toString("ascii") + if (answer.indexOf("JOIN") >= 0) { + assert.equal(answer, ":some_nick JOIN #testchan\r\n") + done() + } + } + mockedSock.destroy = function () { + done("Destroyed socket without answering") + } + + server.emit("connection", mockedSock) + mockedSock.emit('data', Buffer.from("NICK some_nick\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("JOIN #testchan\r\n", "ascii")) + }) + + it("should handle a JOIN command for more than one user -> JOIN #testchan and send join events to all channel members", function (done) { + const server = IRCServer.create() + // helper to count join messages for channel + // we should see 2 of them + let counter = 0 + let mockedSock1 = new EventEmitter() + mockedSock1.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock1.write = function (data) { + let answer = data.toString("ascii") + if (answer.indexOf("JOIN") >= 0 && counter === 1) { + assert.equal(answer, ":other_nick JOIN #testchan\r\n") + done() + } + if (answer === ":some_nick JOIN #testchan\r\n") { + counter++ + } + } + mockedSock1.destroy = function () { + done("Destroyed socket without answering") + } + + let mockedSock2 = new EventEmitter() + mockedSock2.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock2.write = function (data) { + return + } + mockedSock2.destroy = function () { + done("Destroyed socket without answering") + } + + server.emit("connection", mockedSock1) + server.emit("connection", mockedSock2) + mockedSock1.emit('data', Buffer.from("NICK some_nick\r\n", "ascii")) + mockedSock1.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) + mockedSock1.emit('data', Buffer.from("JOIN #testchan\r\n", "ascii")) + mockedSock2.emit('data', Buffer.from("NICK other_nick\r\n", "ascii")) + mockedSock2.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) + mockedSock2.emit('data', Buffer.from("JOIN #testchan\r\n", "ascii")) + }) +}) diff --git a/test/command_nick.js b/test/command_nick.js index 19b0fd1..7c7e720 100644 --- a/test/command_nick.js +++ b/test/command_nick.js @@ -31,7 +31,7 @@ describe("NICK already registered", function () { } let mockedSock2 = new EventEmitter() mockedSock2.write = function (data) { - assert.equal(data.toString("ascii"), "433") + assert.equal(data.toString("ascii"), ":irc.example.com 433 some_nick :Nickname is already in use") done() } mockedSock2.destroy = function () { @@ -49,7 +49,7 @@ describe("NICK no NICK given", function () { const server = IRCServer.create() let mockedSock = new EventEmitter() mockedSock.write = function (data) { - assert.equal(data.toString("ascii"), "431") + assert.equal(data.toString("ascii"), ":irc.example.com 431 :No nickname given") done() } mockedSock.destroy = function () { diff --git a/test/command_pass.js b/test/command_pass.js index 36e605d..0bbdb57 100644 --- a/test/command_pass.js +++ b/test/command_pass.js @@ -4,21 +4,54 @@ const IRCServer = require("../src/server.js"); describe("PASS OK", function () { it("should handle a PASS command -> PASS some_passwd", function (done) { - const server = IRCServer.create() + const server = IRCServer.create({ + password: "itsJustATest" + }) let mockedSock = new EventEmitter() + mockedSock.address = function() { + return { port: 12346, family: 'IPv4', address: '127.0.0.1' } + } + + mockedSock.write = function (data) { + return + } mockedSock.destroy = function () { done("Destroyed socket without answering") } server.emit("connection", mockedSock) - mockedSock.emit('data', Buffer.from("PASS some_pass\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("PASS itsJustATest\r\n", "ascii")) mockedSock.emit('data', Buffer.from("NICK some_nick\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) user = server.getUserlist()["some_nick"] - assert.equal("some_pass", user["password"]) + assert.equal(user["registered"], true) done() + }) + + it("should end connection on wrong password", function (done) { + const server = IRCServer.create({ + password: "itsJustATest" + }) + let mockedSock = new EventEmitter() + + mockedSock.address = function() { + return { port: 12346, family: 'IPv4', address: '127.0.0.1' } + } + + mockedSock.write = function (data) { + return + } + + mockedSock.destroy = function () { + // connection has to be destroyed + done("") + } + + server.emit("connection", mockedSock) + mockedSock.emit('data', Buffer.from("PASS wrongpassword\r\n", "ascii")) }) -}) \ No newline at end of file +}) diff --git a/test/command_privmsg.js b/test/command_privmsg.js index d464e0b..d1d433f 100644 --- a/test/command_privmsg.js +++ b/test/command_privmsg.js @@ -29,8 +29,8 @@ describe("PRIVMSG OK", function () { it("should handle a PRIVMSG command for multiple users -> PRIVMSG other_nick :I'm a message", function (done) { const server = IRCServer.create() let mockedSock1 = new EventEmitter() - mockedSock1.address = function() { - return { port: 12346, family: 'IPv4', address: '127.0.0.1' } + mockedSock1.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} } mockedSock1.write = function (data) { return @@ -39,15 +39,15 @@ describe("PRIVMSG OK", function () { done("Destroyed socket without answering") } let mockedSock2 = new EventEmitter() - mockedSock2.address = function() { - return { port: 12346, family: 'IPv4', address: '127.0.0.1' } + mockedSock2.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} } mockedSock2.write = function (data) { - let answer = data.toString("ascii") - if (answer.indexOf("PRIVMSG") >= 0) { - assert.equal(answer, ":some_nick PRIVMSG other_nick :I'm a message\r\n") - done() - } + let answer = data.toString("ascii") + if (answer.indexOf("PRIVMSG") >= 0) { + assert.equal(answer, ":some_nick PRIVMSG other_nick :I'm a message\r\n") + done() + } } mockedSock2.destroy = function () { done("Destroyed socket without answering") @@ -62,4 +62,67 @@ describe("PRIVMSG OK", function () { mockedSock1.emit('data', Buffer.from("PRIVMSG other_nick :I'm a message\r\n", "ascii")) }) + it("dont send message to unregistered user", function (done) { + const server = IRCServer.create() + let mockedSock1 = new EventEmitter() + mockedSock1.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock1.write = function (data) { + answer = data.toString('ascii') + if (answer.indexOf("401")>=0) { + assert.equal(answer, ":irc.example.com 401 some_nick no such nick/channel") + } + done() + } + mockedSock1.destroy = function () { + done("Destroyed socket without answering") + } + let mockedSock2 = new EventEmitter() + mockedSock2.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock2.write = function (data) { + let answer = data.toString("ascii") + if (answer.indexOf("PRIVMSG") >= 0) { + assert.equal(answer, ":some_nick PRIVMSG other_nick :I'm a message\r\n") + done("I got a message that I shouldn't have seen") + } + } + mockedSock2.destroy = function () { + done("Destroyed socket without answering") + } + + server.emit("connection", mockedSock1) + server.emit("connection", mockedSock2) + mockedSock1.emit('data', Buffer.from("NICK some_nick\r\n", "ascii")) + mockedSock1.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) + mockedSock2.emit('data', Buffer.from("NICK other_nick\r\n", "ascii")) + + mockedSock1.emit('data', Buffer.from("PRIVMSG other_nick :I'm a message\r\n", "ascii")) + }) + + it("should allow to send message to channel -> PRIVMSG #testchan :I'm a message", function (done) { + const server = IRCServer.create() + let mockedSock = new EventEmitter() + mockedSock.address = function () { + return {port: 12346, family: 'IPv4', address: '127.0.0.1'} + } + mockedSock.write = function (data) { + let answer = data.toString("ascii") + if (answer.indexOf("PRIVMSG") >= 0) { + assert.equal(answer, ":some_nick PRIVMSG #testchan :I'm a message\r\n") + done() + } + } + mockedSock.destroy = function () { + done("Destroyed socket without answering") + } + + server.emit("connection", mockedSock) + mockedSock.emit('data', Buffer.from("NICK some_nick\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("USER guest tolmoon tolsun :Ronnie Reagan\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("JOIN #testchan\r\n", "ascii")) + mockedSock.emit('data', Buffer.from("PRIVMSG #testchan :I'm a message\r\n", "ascii")) + }) }) diff --git a/test/command_quit.js b/test/command_quit.js index f4828a8..4ccd8ef 100644 --- a/test/command_quit.js +++ b/test/command_quit.js @@ -11,6 +11,7 @@ describe("QUIT OK", function () { let mockedSock = new EventEmitter() mockedSock.write = function (data) { + done("ERROR: did not expect a message") } server.emit('connection', mockedSock) diff --git a/test/user.js b/test/user.js new file mode 100644 index 0000000..b0d075f --- /dev/null +++ b/test/user.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const User = require("../src/user.js"); + +describe('User', function () { + describe('#contructor()', function () { + it("should create a user while no password is required", function () { + let mockedSock = {} + const user = new User(mockedSock, true) + assert.equal(typeof user, 'object') + }) + + it("should create a user while password is required", function () { + let mockedSock = {} + const user = new User(mockedSock, false) + assert.equal(typeof user, 'object') + }) + }) + + describe('#sendMsg(from, message, to)', function() { + it("should send a message to the user's socket with the user himself as target", function (done) { + let mockedSock = {write: function (data) { + assert.equal(data, ":some_nick PRIVMSG some_nick :test message\r\n") + done() + }} + const user = new User(mockedSock, true) + user.setNickname("some_nick") + user.register("some_nick") + user.sendMsg(user, "test message") + }) + + it("should send a message to the user's socket with a different target", function (done) { + let mockedSock = {write: function (data) { + assert.equal(data, ":some_nick PRIVMSG #testchan :test message\r\n") + done() + }} + const user = new User(mockedSock, true) + user.setNickname("some_nick") + user.register("some_nick") + user.sendMsg(user, "test message", "#testchan") + }) + }) + + describe('#sendRaw(message)', function() { + it("should send a raw command to the user's socket", function(done) { + let mockedSock = {write: function (data) { + assert.equal(data, ":irc.example.com TEST this :command with parameters\r\n") + done() + }} + const user = new User(mockedSock, true) + user.setNickname("some_nick") + user.register("some_nick") + user.sendRaw(":irc.example.com TEST this :command with parameters") + }) + }) + + describe('#closeConnection()', function() { + it("should call destroy on user's socket", function(done) { + let mockedSock = {destroy: function () { + done() + }} + const user = new User(mockedSock, true) + user.closeConnection() + }) + }) + + describe('#setNickname(nickname)', function() { + it('should not answer on inital setting of nickname', function () { + let mockedSock = {} + const user = new User(mockedSock, true) + user.setNickname("some_nick") + assert.equal(user.getNickname(), "some_nick") + }) + + it('should answer with NICK message from original nick on rename', function (done) { + let mockedSock = {write: function (data) { + assert.equal(data, ":some_nick NICK changed_nick\r\n") + done() + }} + const user = new User(mockedSock, true) + user.setNickname("some_nick") + user.setNickname("changed_nick") + }) + }) +})