Skip to content

Commit

Permalink
Add encrypted API to IveBot for dashboard to use.
Browse files Browse the repository at this point in the history
This avoids rate limits by utilising IveBot's guild/member cache.
  • Loading branch information
retrixe committed Aug 3, 2021
1 parent d3b7200 commit d830933
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 47 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Make a file named `config.json5` in the top-level directory. It should be someth
"host": "<your user ID to give you certain privileges like /remoteexec>",
"weatherAPIkey": "<an http://openweathermap.org API key to enable /weather>",
"fixerAPIkey": "<an http://fixer.io API key to enable /currency>",
"jwtSecret": "<optional, leave empty if not using dashboard: JWT secret from dashboard>",
"cvAPIkey": "<a http://cloud.google.com/vision API key for /ocr and text recognition>",
"mongoURL": "<the link to your MongoDB database instance>",
"rootURL": "<the root link to the dashboard with http(s):// and no / at the end>"
Expand Down
7 changes: 5 additions & 2 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ The dashboard component for IveBot. This contains a configuration panel for user

## Configuration

Starting the dashboard itself is identical to IveBot itself i.e. via `yarn build` and `yarn start`, except you must run these in this `dashboard` folder. You must create a file `config.json` in the `dashboard` folder like so:
Starting the dashboard itself is identical to IveBot itself i.e. via `yarn build` and `yarn start`, except you must run these in the `dashboard` folder. You must also get the dependencies in the parent IveBot folder, as these are shared between the two.

The dashboard relies on a private HTTP API IveBot provides. Without this API, it falls back to using `botToken` and hits rate limits, slowing down the real bot as well as the dashboard *hugely*. You must ensure IveBot has access to the internet and is able to port forward `7331` (configurable using `IVEBOT_API_PORT` environment variable when running IveBot). You must also put the same JWT secret the dashboard uses in IveBot's `config.json5` for secured communication. You must create a file `config.json` in the `dashboard` folder like so:

```json
{
"botApiUrl": "<insert URL to IveBot here with http(s)://<hostname>:<port> and no / at the end>",
"botToken": "<insert Discord bot token here>",
"clientId": "<insert Discord client ID here>",
"clientSecret": "<insert Discord client secret here>",
"jwtSecret": "<randomised token to sign JWT and talk with IveBot, the longer the better>",
"jwtSecret": "<randomised token to sign JWTs and talk with IveBot, the longer the better>",
"mongoUrl": "<the link to your MongoDB database instance>",
"host": "<your user ID to give you certain privileges like /remoteexec>",
"rootUrl": "<the root link to the dashboard with http(s):// and no / at the end>"
Expand Down
110 changes: 67 additions & 43 deletions dashboard/imports/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Client } from 'eris'
import { promisify } from 'util'
import { MongoClient, Db, Document } from 'mongodb'
import { JwtPayload, verify, sign } from 'jsonwebtoken'
import { NextApiRequest, NextApiResponse } from 'next'
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto'
import { ApolloError, AuthenticationError, ForbiddenError } from 'apollo-server-micro'
import config from '../config.json'
const { host, rootUrl, mongoUrl, jwtSecret, clientId, clientSecret, botToken } = config
const { host, rootUrl, mongoUrl, jwtSecret, clientId, clientSecret, botToken, botApiUrl } = config

// Create a MongoDB instance.
let db: Db
Expand All @@ -27,9 +29,48 @@ const getServerSettings = async (id: string): Promise<Document> => {
return serverSettings || { id }
}

interface ResolverContext {
req: NextApiRequest
res: NextApiResponse
const encryptionKey = createHash('sha256').update(jwtSecret).digest()
const encrypt = async (data: Buffer): Promise<Buffer> => {
const iv = await promisify(randomBytes)(16)
const cipher = createCipheriv('aes-256-ctr', encryptionKey, iv)
return Buffer.concat([iv, cipher.update(data), cipher.final()])
}
const decrypt = (data: Buffer): Buffer => {
const cipher = createDecipheriv('aes-256-ctr', encryptionKey, data.slice(0, 16))
return Buffer.concat([cipher.update(data.slice(16)), cipher.final()])
}
const getMutualPermissionGuilds = async (id: string, guilds: string[], host = false
): Promise<Array<{ id: string, perm: boolean }>> => {
if (botApiUrl) {
let body: Buffer
try {
body = await encrypt(Buffer.from(JSON.stringify({ id, guilds, host })))
} catch (e) { throw new ApolloError('Failed to encrypt IveBot request!') }
try {
const request = await fetch(`${botApiUrl}/private`, { method: 'POST', body })
if (!request.ok) throw new ApolloError('Failed to make request to IveBot private API!')
return JSON.parse(decrypt(Buffer.from(await request.arrayBuffer())).toString('utf8'))
} catch (e) { throw new ApolloError('Failed to make request to IveBot private API!') }
} else {
const mutualGuildsWithPerm: Array<{ id: string, perm: boolean }> = []
await Promise.all(guilds.map(async guild => {
try {
const fullGuild = await botClient.getRESTGuild(guild)
const selfMember = await botClient.getRESTGuildMember(guild, id)
mutualGuildsWithPerm.push({
id: guild, perm: host || fullGuild.permissionsOf(selfMember).has('manageGuild')
})
} catch (e) {
if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!')
}
}))
return mutualGuildsWithPerm
}
}
const checkUserGuildPerm = async (id: string, guild: string, host = false): Promise<boolean> => {
if (host) return true
const mutuals = await getMutualPermissionGuilds(id, [guild])
return mutuals.length === 1 && mutuals[0].perm
}

const secure = rootUrl.startsWith('https') && process.env.NODE_ENV !== 'development' ? '; Secure' : ''
Expand Down Expand Up @@ -79,22 +120,19 @@ const authenticateRequest = async (req: NextApiRequest, res: NextApiResponse): P
}

// Set up resolvers.
interface ResolverContext {
req: NextApiRequest
res: NextApiResponse
}

export default {
Query: {
getServerSettings: async (parent: string, { id }: { id: string }, context: ResolverContext) => {
const accessToken = await authenticateRequest(context.req, context.res)
const client = new Client(`Bearer ${accessToken}`, { restMode: true })
const self = await client.getSelf()
let hasPerm = false
try {
const fullGuild = await botClient.getRESTGuild(id)
const selfMember = await botClient.getRESTGuildMember(id, self.id)
hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild')
} catch (e) {
if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!')
throw new ForbiddenError('You are not allowed to access this server\'s settings!')
}
if (hasPerm || host === self.id) {
const hasPerm = await checkUserGuildPerm(self.id, id, host === self.id)
if (hasPerm) {
const serverSettings = await getServerSettings(id)
// Insert default values for all properties.
const defaultJoinMsgs = { channel: '', joinMessage: '', leaveMessage: '', banMessage: '' }
Expand Down Expand Up @@ -123,25 +161,19 @@ export default {
const client = new Client(`Bearer ${accessToken}`, { restMode: true })
const guilds = await client.getRESTGuilds()
const self = await client.getSelf()
return (await Promise.all(guilds
.map(async guild => {
/* TODO: Make a custom storage of all guilds IveBot is in to narrow down mutuals before
asking Discord. Current solution is slow and hits rate limits for users in many servers. */
let hasPerm = false
try {
const fullGuild = await botClient.getRESTGuild(guild.id)
const selfMember = await botClient.getRESTGuildMember(guild.id, self.id)
hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild')
} catch (e) { return }
return {
id: guild.id,
name: guild.name,
icon: guild.iconURL || 'no icon',
channels: guild.channels.filter(i => i.type === 0)
.map(i => ({ id: i.id, name: i.name })),
perms: host === self.id || hasPerm
}
}))).filter(e => !!e)
const mutuals = await getMutualPermissionGuilds(self.id, guilds.map(guild => guild.id), host === self.id)
return mutuals.map(mutual => {
const guild = guilds.find(e => e.id === mutual.id)
if (!guild) return null // Should never be hit.
return {
id: guild.id,
name: guild.name,
icon: guild.iconURL || 'no icon',
channels: guild.channels.filter(i => i.type === 0)
.map(i => ({ id: i.id, name: i.name })),
perms: mutual.perm
}
}).filter(e => !!e)
}
},
Mutation: {
Expand All @@ -161,16 +193,8 @@ export default {
const accessToken = await authenticateRequest(context.req, context.res)
const client = new Client(`Bearer ${accessToken}`, { restMode: true })
const self = await client.getSelf()
let hasPerm = false
try {
const fullGuild = await botClient.getRESTGuild(id)
const selfMember = await botClient.getRESTGuildMember(id, self.id)
hasPerm = fullGuild.permissionsOf(selfMember).has('manageGuild')
} catch (e) {
if (e.name === 'DiscordHTTPError') throw new ApolloError('Failed to make Discord request!')
throw new ForbiddenError('You are not allowed to access this server\'s settings!')
}
if (hasPerm || host === self.id) {
const hasPerm = await checkUserGuildPerm(self.id, id, host === self.id)
if (hasPerm) {
const serverSettings = await getServerSettings(id)
// Insert default values for all properties.
const defaultJoinMsgs = { channel: '', joinMessage: '', leaveMessage: '', banMessage: '' }
Expand Down
13 changes: 12 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface Config {
NASAtoken: string
oxfordAPI: { appKey: string, appId: string }
testPilots: string[]
jwtSecret: string
mongoURL: string
rootURL: string
token: string
Expand All @@ -17,7 +18,17 @@ interface Config {
const config: Config = json5.parse(await readFile('config.json5', { encoding: 'utf8' }))

export const {
weatherAPIkey, fixerAPIkey, cvAPIkey, host, NASAtoken, oxfordAPI, testPilots, mongoURL, rootURL, token
weatherAPIkey,
fixerAPIkey,
cvAPIkey,
host,
NASAtoken,
oxfordAPI,
testPilots,
jwtSecret,
mongoURL,
rootURL,
token
} = config

export default Config
60 changes: 59 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { MongoClient } from 'mongodb'
// Import fs.
import { readdir, stat } from 'fs/promises'
import { inspect } from 'util'
import http from 'http'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
// Import types.
import { DB, Command } from './imports/types.js'
// Import the bot.
import CommandParser from './client.js'
import { guildMemberAdd, guildMemberRemove, guildDelete, guildBanAdd } from './events.js'
// Get the token needed.
import { token, mongoURL } from './config.js'
import { token, mongoURL, jwtSecret } from './config.js'

// If production is explicitly specified via flag..
if (process.argv[2] === '--production') process.env.NODE_ENV = 'production'
Expand Down Expand Up @@ -110,3 +112,59 @@ client.on('error', (err: Error, id: string) => {

// Connect to Discord.
await client.connect()

// Start private HTTP API for the dashboard.
if (jwtSecret) {
const key = createHash('sha256').update(jwtSecret).digest()
const headers = (body: NodeJS.ArrayBufferView | string): {} => ({
'Content-Length': Buffer.byteLength(body), 'Content-Type': 'application/json'
})
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/private') return
let buffer = Buffer.from([])
req.on('data', chunk => {
buffer = Buffer.concat([buffer, Buffer.from(chunk)])
if (buffer.byteLength > 1024 * 8) req.destroy() // 8 kB limit
})
req.on('end', () => {
(async () => {
try {
const decipher = createDecipheriv('aes-256-ctr', key, buffer.slice(0, 16))
const data = Buffer.concat([decipher.update(buffer.slice(16)), decipher.final()])
const valid: Array<{ id: string, perm: boolean }> = []
const parsed: { id: string, host: boolean, guilds: string[] } = JSON.parse(data.toString('utf8'))
if (typeof parsed.id !== 'string' || !Array.isArray(parsed.guilds)) throw new Error()
await Promise.all(parsed.guilds.map(async id => {
if (typeof id !== 'string' || id.length <= 16) return
const guild = client.guilds.get(id)
if (!guild) return
else if (parsed.host) return valid.push({ id, perm: true }) // Fast path.
let member = guild.members.get(parsed.id)
if (!member) {
try {
member = await client.getRESTGuildMember(id, parsed.id)
guild.members.add(member) // Cache the member for faster lookups.
} catch (e) {} // TODO: Unable to retrieve member for the guild. Hm?
}
if (member) valid.push({ id, perm: guild.permissionsOf(member).has('manageGuild') })
}))
randomBytes(16, (err, iv) => {
if (err) {
const error = '{"error":"Internal Server Error!"}'
return res.writeHead(500, headers(error)).end(error)
}
const cipher = createCipheriv('aes-256-ctr', key, iv)
const data = Buffer.from(JSON.stringify(valid))
const aesData = Buffer.concat([iv, cipher.update(data), cipher.final()])
return res.writeHead(200, headers(aesData)).end(aesData)
})
} catch (e) {
const error = '{"error":"Invalid body!"}'
return res.writeHead(400, headers(error)).end(error)
}
})().catch(console.error)
})
}).listen(isNaN(+process.env.IVEBOT_API_PORT) ? 7331 : +process.env.IVEBOT_API_PORT, () => {
console.log('Listening for IveBot dashboard requests on', server.address())
})
}

0 comments on commit d830933

Please sign in to comment.