From 43e974f4c6ce68f3d977c537b46c12817912bc3c Mon Sep 17 00:00:00 2001 From: emerson Date: Sun, 5 Dec 2021 18:33:49 -0500 Subject: [PATCH] basic classes --- package-lock.json | 50 ++++++++++++ src/Channel.ts | 31 ++++++++ src/Client.ts | 198 ++++++++++++++++++++++++++++++++++++++++++++++ src/IRCUser.ts | 35 ++++++++ src/MatrixUser.ts | 32 ++++++++ src/Message.ts | 123 ++++++++++++++++++++++++++++ src/Server.ts | 24 ++++++ src/numerics.ts | 52 ++++++++++++ 8 files changed, 545 insertions(+) create mode 100644 package-lock.json create mode 100644 src/Channel.ts create mode 100644 src/Client.ts create mode 100644 src/IRCUser.ts create mode 100644 src/MatrixUser.ts create mode 100644 src/Message.ts create mode 100644 src/Server.ts create mode 100644 src/numerics.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6a8c871 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "reflectionircd", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "reflectionircd", + "version": "0.0.1", + "license": "GPL-3.0", + "devDependencies": { + "@types/node": "^16.11.11", + "typescript": "^4.5.2" + } + }, + "node_modules/@types/node": { + "version": "16.11.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.11.tgz", + "integrity": "sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw==", + "dev": true + }, + "node_modules/typescript": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", + "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "@types/node": { + "version": "16.11.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.11.tgz", + "integrity": "sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw==", + "dev": true + }, + "typescript": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", + "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "dev": true + } + } +} diff --git a/src/Channel.ts b/src/Channel.ts new file mode 100644 index 0000000..28118e9 --- /dev/null +++ b/src/Channel.ts @@ -0,0 +1,31 @@ +import { Server } from "./Server.js"; +import { MatrixUser } from "./MatrixUser.js"; +import { IRCUser } from "./IRCUser.js"; +import { IRCMessage } from "./Message.js"; + +export class Channel { + public name: string + private matrixUsers: Map + private ircUsers: Map + private nickToMXid: Map + private powerLevels: Map + private topic: Map; + private memberCount: number; + private modes: Map + private messages: Map; + private tsToEventId: Map; + constructor(public roomId: string, private server: Server, initialIRCUser: IRCUser) { + this.name = roomId; + this.matrixUsers = new Map(); + this.ircUsers = new Map(); + this.ircUsers.set(initialIRCUser.nick, initialIRCUser); + this.nickToMXid = new Map(); + this.powerLevels = new Map(); + this.topic = new Map(); + this.memberCount = 0; + this.modes = new Map(); + this.modes.set('n', ''); + this.messages = new Map(); + this.tsToEventId = new Map(); + } +} \ No newline at end of file diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 0000000..822ea65 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,198 @@ +import { Socket } from 'net'; +import { IRCUser } from './IRCUser.js'; +import { IRCMessage, parseIRCMessage } from './Message.js'; +import numerics from './numerics.js'; +import { Server } from './Server.js'; + +export class Client { + user: IRCUser|null + capVersion: string + enabledCaps: Map + allCaps: Map + localNick: string + localUsername: string + localRealname: string + constructor(private socket: Socket, public server: Server) { + this.user = null; + this.capVersion = '301'; + this.enabledCaps = new Map(); + this.allCaps = new Map([ + ["account-tag", ""], + ["batch", ""], + ["draft/chathistory", ""], + ["echo-message", ""], + ["draft/event-playback", ""], + ["invite-notify", ""], + ["message-tags", ""], + ["sasl", "PLAIN"], + ["server-time", ""], + ]); + this.localNick = 'none'; + this.localUsername = 'none'; + this.localRealname = 'none'; + this.socket.on('data', (data) => this.receiveData(data)); + //this.socket.on('close', (e) => {if (this.user) this.user.handleClientClose(this, e)}); + } + + receiveData(data: Buffer|String) { + const dataArray = data.toString().split('\r\n'); + dataArray.forEach(m => { + const trimmedMsg = m.replace('\r', '').replace('\n', ''); + if (trimmedMsg !== '') + this.routeMessage(trimmedMsg); + }); + } + + routeMessage(data: string) { + const message = parseIRCMessage(data); + switch (message.command.toUpperCase()) { + case 'AUTHENTICATE': { + this.doAUTHENTICATE(message); + break; + } + case 'CAP': { + this.doCAP(message); + break; + } + } + } + + getCapString(capVersion: string) { + let capArray = []; + for (const [key, value] of this.allCaps.entries()) { + if (capVersion === '301' || value.length === 0) { + capArray.push(key); + } + else { + capArray.push(`${key}=${value}`); + } + } + return capArray.join(' '); + } + + doCAP(message: IRCMessage) { + switch (message.params[0]) { + case 'LS': { + if (message.params.length === 2) { + this.capVersion = message.params[1]; + } + this.sendMessage(this.server.name, "CAP", ["*", "LS", this.getCapString(this.capVersion)], message.tags); + break; + } + case 'LIST': { + this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)], message.tags); + break; + } + case 'REQ': { + const capsToChange = (message.params[1].indexOf(' ') === -1) ? [message.params[1]] : message.params[1].split(' '); + const capsEnabled: string[] = []; + capsToChange.forEach(cap => { + if (cap in this.allCaps) { + this.enabledCaps.set(cap, ''); + capsEnabled.push(cap); + } + }); + this.sendMessage(this.server.name, "CAP", ["*", "ACK", capsEnabled.join(' ')], message.tags); + break; + } + case 'END': { + if (this.user !== null) { + this.doRegistration(message); + } + else { + this.closeConnectionWithError("You must use SASL to connect to this server"); + } + } + } + } + + doAUTHENTICATE(message: IRCMessage) { + if (message.params[0] === "PLAIN") { + this.sendMessage("", "AUTHENTICATE", ["+"], message.tags); + } + else { + const authArray = Buffer.from(message.params[0], 'base64').toString('utf-8').split('\0'); + if (!authArray || authArray.length !== 3) { + this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) + this.closeConnectionWithError('Invalid authentication') + } + const mxid = authArray[1]; + const accessToken = authArray[2]; + const thisIRCUser = this.server.ircUsers.get(mxid) || new IRCUser(this, mxid, accessToken); + if (thisIRCUser.isAuthed) { + if (!thisIRCUser.verifyCredentials(accessToken)) { + this.sendMessage(this.server.name, '904', numerics['904']('*'), message.tags) + this.closeConnectionWithError('Invalid authentication') + } + else { + this.user = thisIRCUser; + } + } + } + } + + doRegistration(message: IRCMessage) { + if (this.user === null) { + this.closeConnectionWithError('Authentication failed'); + return; + } + this.sendMessage(this.server.name, '001', numerics['001'](this.user.nick, this.server.name), message.tags); + this.sendMessage(this.server.name, '002', numerics['002'](this.user.nick, this.server.name, '0.0.1'), message.tags); + this.sendMessage(this.server.name, '003', numerics['003'](this.user.nick, 'yesterday'), message.tags); + this.sendMessage(this.server.name, '004', numerics['004'](this.user.nick, this.server.name, '0.0.1', 'i', 'Lhionpsv'), message.tags); + const iSupportArray = [ + 'CASEMAPPING=ascii', + 'CHANMODES=,,,Linps', + 'CHANTYPES=#&!', + 'MAXTARGETS=1', + 'PREFIX=(ohv)@%+', + ] + if (this.enabledCaps.has('draft/chathistory')) { + iSupportArray.push('CHATHISTORY=50'); + } + this.sendMessage(this.server.name, '005', numerics['005'](this.user.nick, iSupportArray), message.tags); + + this.sendMessage(this.server.name, '375', numerics['375'](this.user.nick), message.tags); + this.sendMessage(this.server.name, '372', numerics['372'](this.user.nick, "It's an MOTD"), message.tags); + this.sendMessage(this.server.name, '376', numerics['376'](this.user.nick), message.tags); + + this.sendMessage(this.user.nick, 'MODE', [this.user.nick, '+i'], message.tags); + } + + sendMessage(prefix: string, command: string, params: string[], tags: Map) { + const capTagMapping = new Map([ + ['account', 'account-tag'], + ['label', 'labeled-response'], + ['msgid', 'message-tags'], + ['reflectionircd.chat/delete-message', 'reflectionircd.chat/delete-message'], + ['reflectionircd.chat/edit-message', 'reflectionircd.chat/edit-message'], + ['time', 'server-time'], + ]) + const ourTags: Map = new Map(); + if (this.enabledCaps.has('server-time') && !tags.has('time')) + ourTags.set('time', new Date().toISOString()); + + tags.forEach((v, k) => { + if (k.startsWith('+')) { + if (this.enabledCaps.has('message-tags')) { + ourTags.set(k, v); + } + } + else { + const capToCheck = capTagMapping.get(k) || ''; + if (this.enabledCaps.has(capToCheck)) { + ourTags.set(k, v); + } + } + }) + const newMsg = new IRCMessage(ourTags, prefix, command, params); + const msgToSend = newMsg.toString(); + console.log(`SENT: ${msgToSend}`); + this.socket.write(`${msgToSend}\r\n`); + } + + closeConnectionWithError(message: string) { + this.sendMessage(this.server.name, 'ERROR', [message], new Map()); + this.socket.destroy(); + } +} \ No newline at end of file diff --git a/src/IRCUser.ts b/src/IRCUser.ts new file mode 100644 index 0000000..6b8b026 --- /dev/null +++ b/src/IRCUser.ts @@ -0,0 +1,35 @@ +import { Channel } from "./Channel.js"; +import { Client } from "./Client.js"; +import { MatrixUser } from "./MatrixUser.js"; +import { IRCMessage } from "./Message.js"; +import { Server } from "./Server.js"; + +export class IRCUser { + private clients: Set + private channels: Map + private server: Server + private matrixUser: MatrixUser|null + public nick: string + private ident: string + private hostname: string + public accountName: string + public isAuthed: boolean + private txnIdStore: Set + constructor(private initialClient: Client, public mxid: string, private accessToken: string) { + this.clients = new Set([initialClient]); + this.channels = new Map(); + this.server = initialClient.server; + this.matrixUser = null; + const mxidSplit = mxid.split(':') + this.nick = mxidSplit[0].substr(1); + this.ident = this.nick; + this.hostname = mxidSplit[1]; + this.accountName = mxid.slice(1); + this.isAuthed = false; + this.txnIdStore = new Set(); + } + + verifyCredentials(accessToken: string): boolean { + return accessToken === this.accessToken; + } +} \ No newline at end of file diff --git a/src/MatrixUser.ts b/src/MatrixUser.ts new file mode 100644 index 0000000..5c583bb --- /dev/null +++ b/src/MatrixUser.ts @@ -0,0 +1,32 @@ +import { Channel } from "./Channel.js" + +export class MatrixUser { + public ident: string + public hostname: string + public realname: string + public accountName: string + public channels: Set + constructor(public mxid: string, public nick: string) { + const mxidSplit = this.mxid.split(':') + this.ident = mxidSplit[0].substr(1); + this.hostname = mxidSplit[1]; + this.realname = this.mxid; + this.accountName = this.mxid.slice(1); + this.channels = new Set(); + } + + getMask(): string { + return `${this.nick}!${this.ident}@${this.hostname}`; + } + + addChannel(channel: Channel) { + this.channels.add(channel); + } + + setRealname(realname: string) { + if (this.realname === realname.substring(0, 64)) + return; + + this.realname = realname.substring(0, 64); + } +} \ No newline at end of file diff --git a/src/Message.ts b/src/Message.ts new file mode 100644 index 0000000..ae6b455 --- /dev/null +++ b/src/Message.ts @@ -0,0 +1,123 @@ +export class IRCMessage { + constructor( + public tags: Map, + public prefix: string, + public command: string, + public params: string[], + ) {} + toString() { + let messageString = ''; + if (this.tags.size !== 0) { + let tagArray = []; + for (const [key, value] of this.tags) { + if (value === '') { + tagArray.push(key); + } + else { + tagArray.push(`${key}=${encodeTag(value)}`); + } + } + messageString = `@${tagArray.join(";")} `; + } + + if (this.prefix) { + messageString = `${messageString}:${this.prefix} `; + } + + messageString = `${messageString}${this.command}`; + + const params = this.params.slice(); + + let lastParam = params.pop(); + if (lastParam) { + if (lastParam.indexOf(' ') !== -1) { + lastParam = `:${lastParam}`; + } + params.push(lastParam); + } + messageString = `${messageString} ${params.join(' ')}`; + return messageString; + } +} + +function decodeTag(value: string): string { + const tagDecodeMap = new Map([ + ['\\:', ';'], + ['\\s', ' '], + ['\\\\', '\\'], + ['\\r', '\r'], + ['\\n', '\n'], + ['\\', ''], + ]); + return value.replace(/\\:|\\s|\\\\|\\r|\\n|\\/gi, t => tagDecodeMap.get(t) || '') +} + +function encodeTag(value: string): string { + const tagEncodeMap = new Map([ + [';', '\\:'], + [' ', '\\s'], + ['\\', '\\\\'], + ['\r', '\\r'], + ['\n', '\\n'], + ]); + return value.replace(/;| |\\|\r|\n/gi, t => tagEncodeMap.get(t) || ''); +} + +function addToTags(key: string): boolean { + const tagsToPass = [ + 'batch', + 'label', + ] + return (tagsToPass.includes(key) || key.startsWith('+')); +} + +export function parseIRCMessage(rawLine: string) { + console.log(`RAW: ${rawLine}`); + let restOfMessage = rawLine; + let parsedTags: Map = new Map(); + let prefix = ''; + let command = ''; + let params: string[] = []; + if (rawLine.startsWith('@')) { + const tags = restOfMessage.substr(0, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ')+1); + + for (const tag in tags.split(';')) { + const valueSplit = tag.indexOf('='); + if (valueSplit === -1 && addToTags(tag)) { + parsedTags.set(tag, ''); + continue; + } + const key = tag.substr(0, valueSplit); + const value = tag.substr(valueSplit); + if (addToTags(key)) + parsedTags.set(key, decodeTag(value)); + } + } + if (restOfMessage.startsWith(':')) { + prefix = restOfMessage.substr(0, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ')+1); + } + + if (restOfMessage.indexOf(' ') === -1) { + command = restOfMessage; + console.log(parsedTags, prefix, command, params); + return new IRCMessage(parsedTags, prefix, command, params); + } + + command = restOfMessage.substr(0, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ') + 1); + + let lastParam = ''; + if (restOfMessage.indexOf(' :') !== -1) { + lastParam = restOfMessage.substr(restOfMessage.indexOf(' :') + 2); + restOfMessage = restOfMessage.substr(0, restOfMessage.indexOf(' :')); + } + + params = restOfMessage.split(' '); + if (lastParam !== '') { + params.push(lastParam); + } + console.log(parsedTags, prefix, command, params); + return new IRCMessage(parsedTags, prefix, command, params); +} \ No newline at end of file diff --git a/src/Server.ts b/src/Server.ts new file mode 100644 index 0000000..ec41ebe --- /dev/null +++ b/src/Server.ts @@ -0,0 +1,24 @@ +import { Channel } from "./Channel.js" +import { IRCUser } from "./IRCUser.js" +import { MatrixUser } from "./MatrixUser.js" + +export class Server { + public name: string + // + public matrixRooms: Map + // + public ircChannels: Map + // + public matrixUsers: Map + // + public ircUsers: Map + public nickToMxid: Map + constructor(public config: any) { + this.name = this.config.serverName; + this.matrixRooms = new Map(); + this.ircChannels = new Map(); + this.matrixUsers = new Map(); + this.ircUsers = new Map(); + this.nickToMxid = new Map(); + } +} \ No newline at end of file diff --git a/src/numerics.ts b/src/numerics.ts new file mode 100644 index 0000000..3ee6030 --- /dev/null +++ b/src/numerics.ts @@ -0,0 +1,52 @@ +const numerics = { + "001": (nick: string, serverName: string) => { + return [nick, `Welcome to the ${serverName} network, ${nick}`] + }, + "002": (nick: string, serverName: string, version: string) => { + return [nick, `Your host is ${serverName}, running version ${version}`] + }, + "003": (nick: string, createdTime: string) => { + return [nick, `This server was created ${createdTime}`] + }, + "004": (nick: string, serverName: string, version: string, umodes: string, cmodes: string) => { + return [nick, serverName, version, umodes, cmodes] + }, + "005": (nick: string, isupportArray: string[]) => { + return [nick, `${isupportArray.join(' ')} :are supported by this server`] + }, + "221": (nick: string, umode: string) => { + return [nick, umode] + }, + "324": (nick: string, channel: string, modeString: string) => { + return [nick, channel, modeString]; + }, + "353": (nick: string, symbol: string, channel: string, nameArray: string[]) => { + return [nick, symbol, channel, `${nameArray.join(' ')}`] + }, + "366": (nick: string, channel: string) => { + return [nick, channel, "End of /NAMES list"] + }, + "372": (nick: string, motdLine: string) => { + return [nick, motdLine] + }, + "375": (nick: string) => { + return [nick, `- Start of MOTD`] + }, + "376": (nick: string) => { + return [nick, "End of MOTD"] + }, + "433": (nick: string, otherNick: string) => { + return [nick, otherNick, "You can't change nicks on IRC yet"] + }, + "900": (mask: string, nick: string) => { + return [nick, mask, `You are now logged in as ${nick}`] + }, + "903": (nick: string) => { + return [nick, "SASL authentication successful"] + }, + "904": (nick: string) => { + return [nick, "SASL authentication failed"] + }, +} + +export default numerics; \ No newline at end of file