288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
// -*- mode: javascript; indent-tabs-mode: nil -*-
|
|
// Copyright (C) 2021 Noisytoot
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
const fs = require('fs')
|
|
const net = require('net')
|
|
const process = require('process')
|
|
const util = require('util')
|
|
const irc = require('irc-framework')
|
|
const ed25519 = require('ed25519')
|
|
|
|
const random = fs.openSync('/dev/urandom', 'r+')
|
|
function getRandom(bytes) {
|
|
let buffer = new Buffer.alloc(bytes)
|
|
fs.readSync(random, buffer, 0, bytes, 0)
|
|
return buffer
|
|
}
|
|
function putRandom(buffer) {
|
|
fs.writeSync(random, buffer, 0, buffer.length, 0)
|
|
}
|
|
|
|
const defaultConfig = {
|
|
port: 6667,
|
|
nick: 'randomsync',
|
|
user: 'randomsync',
|
|
source: 'https://git.sr.ht/~noisytoot/randomsync',
|
|
tls: false,
|
|
'bytes-max': 64,
|
|
'sync-interval': 900000, // 15 minutes in milliseconds
|
|
'leader-probability': 0.75,
|
|
'otp-length': 32
|
|
}
|
|
|
|
function rehash() {
|
|
let config = Object.assign(defaultConfig, JSON.parse(fs.readFileSync('./config.json', 'utf8')))
|
|
|
|
// Set realname to source if not defined
|
|
if (!config.realname)
|
|
config.realname = config.source
|
|
|
|
// Make SASL_MECHANISM uppercase if defined
|
|
if (config['sasl-mechanism'])
|
|
config['sasl-mechanism'] = config['sasl-mechanism'].toUpperCase()
|
|
|
|
// Decode control key if defined
|
|
if (config['control-key'])
|
|
config['control-key'] = Buffer.from(config['control-key'], 'base64')
|
|
|
|
// Enable all features for all channels if only channels is defined
|
|
if (config['randomsync-channels'] || config['shrug-channels']) {
|
|
config.channels = Array.from(new Set([...config['randomsync-channels'], ...config['shrug-channels']]))
|
|
} else if (config.channels) {
|
|
config['randomsync-channels'] = config.channels
|
|
config['shrug-channels'] = config.channels
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
function handleControlMessage(args) {
|
|
switch (args[0]) {
|
|
case "CONFIG":
|
|
if (args.length >= 2)
|
|
return util.inspect(config[args[1]], { breakLength: Infinity, compact: true })
|
|
break
|
|
case "REHASH":
|
|
config = rehash()
|
|
return 'Rehashed configuration file'
|
|
break
|
|
case "RAW":
|
|
client.raw(args.slice(1).join(' '))
|
|
break
|
|
case "LASTSYNC":
|
|
return JSON.stringify(lastSync)
|
|
break
|
|
case "SYNCLEADER":
|
|
return JSON.stringify(syncLeader)
|
|
break
|
|
case "OTP":
|
|
if (!otp)
|
|
otp = getRandom(config['otp-length']).toString('base64')
|
|
return otp
|
|
break
|
|
}
|
|
}
|
|
|
|
let config = rehash()
|
|
|
|
let key, cert
|
|
if (config['tls-key'] && config['tls-cert']) {
|
|
key = fs.readFileSync(config['tls-key'], 'utf8')
|
|
cert = fs.readFileSync(config['tls-cert'], 'utf8')
|
|
}
|
|
|
|
let lastSync = 0,
|
|
syncLeader = false,
|
|
otp
|
|
|
|
let client = new irc.Client({
|
|
nick: config.nick,
|
|
host: config.server,
|
|
port: config.port,
|
|
username: config.user,
|
|
gecos: config.realname,
|
|
tls: config.tls,
|
|
rejectUnauthorized: config['tls-verify'],
|
|
outgoing_addr: config['outgoing-addr'],
|
|
password: config.password,
|
|
client_certificate: {
|
|
private_key: key,
|
|
certificate: cert
|
|
},
|
|
version: 'randomsync <' + config.source + '>',
|
|
sasl_mechanism: config['sasl-mechanism'],
|
|
account: {
|
|
account: config['sasl-username'],
|
|
password: config['sasl-password']
|
|
}
|
|
})
|
|
|
|
client.connect()
|
|
|
|
client.on('registered', event => {
|
|
if (config['registered-commands']) {
|
|
for (command of config['registered-commands'])
|
|
client.raw(command)
|
|
}
|
|
if (config.mode) client.raw("MODE", client.user.nick, config.mode)
|
|
for (channel of config.channels) {
|
|
client.join(channel)
|
|
}
|
|
})
|
|
|
|
client.on('privmsg', msg => {
|
|
putRandom(msg.message)
|
|
if (config.channels.includes(msg.target)) {
|
|
if (msg.message.includes('$RANDSYNC-OTP$ ' + (config['control-id'] || client.user.nick))) {
|
|
if (!otp)
|
|
otp = getRandom(config['otp-length']).toString('base64')
|
|
client.say(msg.target, otp)
|
|
}
|
|
if (msg.message.includes('$RANDSYNC-CONTROL$ ' + (config['control-id'] || client.user.nick))
|
|
&& config['control-key']) {
|
|
let args = msg.message.split(' ')
|
|
args = args.slice(args.indexOf('$RANDSYNC-CONTROL$') + 2)
|
|
if (args.length >= 3) {
|
|
let signature = Buffer.from(args[0], 'base64')
|
|
if (signature.length == 64
|
|
&& ed25519.Verify(Buffer.from(args.slice(1).join(' '), 'utf8'), signature, config['control-key'])
|
|
&& otp == args[1]) {
|
|
args = args.slice(2)
|
|
otp = undefined
|
|
let result = handleControlMessage(args)
|
|
if (result != undefined)
|
|
client.say(msg.target, result)
|
|
} else {
|
|
client.say(msg.target, 'Invalid signature or OTP')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (config['shrug-channels'].includes(msg.target)
|
|
&& config['shrug-prefix']
|
|
&& msg.message.startsWith(config['shrug-prefix'] + 'shrug'))
|
|
client.say(msg.target, '¯\\_(ツ)_/¯')
|
|
if (config['randomsync-channels'].includes(msg.target)) {
|
|
if (msg.message.includes('$RANDSYNCv2$')) {
|
|
let args = msg.message.split(' ')
|
|
args = args.slice(args.indexOf('$RANDSYNCv2$') + 1)
|
|
switch (args[0]) {
|
|
case "REQUEST":
|
|
if (args.length < 2) {
|
|
client.say(msg.target, 'ERROR Invalid arguments')
|
|
break
|
|
}
|
|
let bytes = parseInt(args[1])
|
|
if (isNaN(bytes) || bytes <= 0 || bytes > config['bytes-max']) {
|
|
client.say(msg.target, 'ERROR Invalid byte amount (max: ' + config['bytes-max'] + ')')
|
|
break
|
|
}
|
|
client.say(msg.target, '$RANDSYNCv2$ SEND !random-sync ' + getRandom(bytes).toString('base64'))
|
|
break
|
|
case "REQUESTI":
|
|
if (args.length < 3) {
|
|
client.say(msg.target, 'ERROR Invalid arguments')
|
|
break
|
|
}
|
|
let nick = args[1]
|
|
if (nick == client.user.nick) {
|
|
let bytes = parseInt(args[2])
|
|
if (isNaN(bytes) || bytes <= 0 || bytes > config['bytes-max']) {
|
|
client.say(msg.target, 'ERROR Invalid byte amount (max: ' + config['bytes-max'] + ')')
|
|
break
|
|
}
|
|
client.say(msg.target, '$RANDSYNCv2$ SEND !random-sync ' + getRandom(bytes).toString('base64'))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if (msg.message.includes('$RANDSYNCv1$')) {
|
|
let args = msg.message.split(' ')
|
|
args = args.slice(args.indexOf('$RANDSYNCv1$') + 1)
|
|
switch (args[0]) {
|
|
case "REQUEST":
|
|
if (args.length < 2) {
|
|
client.say(msg.target, 'ERROR ' + msg.nick + ' Invalid arguments')
|
|
break
|
|
}
|
|
let bytes = parseInt(args[1])
|
|
if (isNaN(bytes) || bytes <= 0 || bytes > config['bytes-max']) {
|
|
client.say(msg.target, 'ERROR ' + msg.nick + ' Invalid byte amount (max: ' + config['bytes-max'] + ')')
|
|
break
|
|
}
|
|
client.say(msg.target, '$RANDSYNCv1$ RESPOND ' + msg.nick + ' !random-sync ' + getRandom(bytes).toString('base64'))
|
|
break
|
|
case "REQUESTN":
|
|
if (args.length < 3) {
|
|
client.say(msg.target, 'ERROR ' + msg.nick + ' Invalid arguments')
|
|
break
|
|
}
|
|
let nick = args[1]
|
|
if (nick == client.user.nick) {
|
|
let bytes = parseInt(args[2])
|
|
if (isNaN(bytes) || bytes <= 0 || bytes > config['bytes-max']) {
|
|
client.say(msg.target, 'ERROR ' + msg.nick + ' Invalid byte amount (max: ' + config['bytes-max'] + ')')
|
|
break
|
|
}
|
|
client.say(msg.target, '$RANDSYNCv1$ RESPOND ' + msg.nick + ' ' + getRandom(bytes).toString('base64'))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if (msg.message.includes('!do-random-sync')) {
|
|
client.say(msg.target, '!random-sync ' + getRandom(64).toString('base64'))
|
|
lastSync = Date.now()
|
|
syncLeader = false
|
|
}
|
|
}
|
|
})
|
|
|
|
client.on('invite', invite => {
|
|
if (invite.invited == client.user.nick && config.channels.includes(invite.channel))
|
|
client.join(invite.channel)
|
|
})
|
|
|
|
client.on('ctcp request', event => {
|
|
if (event.type && event.type.toUpperCase() == 'SOURCE')
|
|
client.ctcpResponse(event.nick, 'SOURCE', config.source)
|
|
})
|
|
|
|
setInterval(() => {
|
|
if (!syncLeader && lastSync + config['sync-interval'] * 1.5 < Date.now()
|
|
&& Math.random() < config['leader-probability']) syncLeader = true
|
|
if (syncLeader) {
|
|
for (channel of config['randomsync-channels']) {
|
|
client.say(channel, '$RANDSYNCv2$ SEND !do-random-sync ' + getRandom(64).toString('base64'))
|
|
}
|
|
lastSync = Date.now()
|
|
}
|
|
}, config['sync-interval'])
|
|
|
|
let controlServer = net.createServer(c => {
|
|
c.on('data', data => {
|
|
let response = handleControlMessage(data.toString('utf8').trim().split(' '))
|
|
if (response)
|
|
c.write(response + '\n')
|
|
})
|
|
})
|
|
|
|
if (config['control-socket'])
|
|
controlServer.listen(config['control-socket'])
|
|
|
|
process.on('exit', code => {
|
|
controlServer.close()
|
|
})
|