UNPKG

9.26 kBPlain TextView Raw
1import reduct = require('reduct')
2import Ajv = require('ajv')
3import { mapValues as pluck } from 'lodash'
4import Accounts from './accounts'
5import Config from './config'
6import MiddlewareManager from './middleware-manager'
7import BalanceMiddleware from '../middlewares/balance'
8import AlertMiddleware from '../middlewares/alert'
9import RoutingTable from './routing-table'
10import RouteBroadcaster from './route-broadcaster'
11import Stats from './stats'
12import RateBackend from './rate-backend'
13import { formatRoutingTableAsJson } from '../routing/utils'
14import { Server, IncomingMessage, ServerResponse } from 'http'
15import InvalidJsonBodyError from '../errors/invalid-json-body-error'
16import { BalanceUpdate } from '../schemas/BalanceUpdate'
17import { create as createLogger } from '../common/log'
18import * as Prometheus from 'prom-client'
19const log = createLogger('admin-api')
20const ajv = new Ajv()
21const validateBalanceUpdate = ajv.compile(require('../schemas/BalanceUpdate.json'))
22
23interface Route {
24 method: 'GET' | 'POST' | 'DELETE'
25 match: string
26 fn: (url: string | undefined, body: object) => Promise<object | string | void>
27 responseType?: string
28}
29
30export default class AdminApi {
31 private accounts: Accounts
32 private config: Config
33 private middlewareManager: MiddlewareManager
34 private routingTable: RoutingTable
35 private routeBroadcaster: RouteBroadcaster
36 private rateBackend: RateBackend
37 private stats: Stats
38
39 private server?: Server
40 private routes: Route[]
41
42 constructor (deps: reduct.Injector) {
43 this.accounts = deps(Accounts)
44 this.config = deps(Config)
45 this.middlewareManager = deps(MiddlewareManager)
46 this.routingTable = deps(RoutingTable)
47 this.routeBroadcaster = deps(RouteBroadcaster)
48 this.rateBackend = deps(RateBackend)
49 this.stats = deps(Stats)
50
51 this.routes = [
52 { method: 'GET', match: '/status$', fn: this.getStatus },
53 { method: 'GET', match: '/routing$', fn: this.getRoutingStatus },
54 { method: 'GET', match: '/accounts$', fn: this.getAccountStatus },
55 { method: 'GET', match: '/accounts/', fn: this.getAccountAdminInfo },
56 { method: 'POST', match: '/accounts/', fn: this.sendAccountAdminInfo },
57 { method: 'GET', match: '/balance$', fn: this.getBalanceStatus },
58 { method: 'POST', match: '/balance$', fn: this.postBalance },
59 { method: 'GET', match: '/rates$', fn: this.getBackendStatus },
60 { method: 'GET', match: '/stats$', fn: this.getStats },
61 { method: 'GET', match: '/alerts$', fn: this.getAlerts },
62 { method: 'DELETE', match: '/alerts/', fn: this.deleteAlert },
63 { method: 'GET', match: '/metrics$', fn: this.getMetrics, responseType: Prometheus.register.contentType },
64 { method: 'POST', match: '/addAccount$', fn: this.addAccount }
65 ]
66 }
67
68 listen () {
69 const {
70 adminApi = false,
71 adminApiHost = '127.0.0.1',
72 adminApiPort = 7780
73 } = this.config
74
75 log.info('listen called')
76
77 if (adminApi) {
78 log.info('admin api listening. host=%s port=%s', adminApiHost, adminApiPort)
79 this.server = new Server()
80 this.server.listen(adminApiPort, adminApiHost)
81 this.server.on('request', (req, res) => {
82 this.handleRequest(req, res).catch((e) => {
83 let err = e
84 if (!e || typeof e !== 'object') {
85 err = new Error('non-object thrown. error=' + e)
86 }
87
88 log.warn('error in admin api request handler. error=%s', err.stack ? err.stack : err)
89 res.statusCode = e.httpErrorCode || 500
90 res.setHeader('Content-Type', 'text/plain')
91 res.end(String(err))
92 })
93 })
94 }
95 }
96
97 private async handleRequest (req: IncomingMessage, res: ServerResponse) {
98 req.setEncoding('utf8')
99 let body = ''
100 await new Promise((resolve, reject) => {
101 req.on('data', data => body += data)
102 req.once('end', resolve)
103 req.once('error', reject)
104 })
105
106 const urlPrefix = (req.url || '').split('?')[0] + '$'
107 const route = this.routes.find((route) =>
108 route.method === req.method && urlPrefix.startsWith(route.match))
109 if (!route) {
110 res.statusCode = 404
111 res.setHeader('Content-Type', 'text/plain')
112 res.end('Not Found')
113 return
114 }
115
116 const resBody = await route.fn.call(this, req.url, body && JSON.parse(body))
117 if (resBody) {
118 res.statusCode = 200
119 if (route.responseType) {
120 res.setHeader('Content-Type', route.responseType)
121 res.end(resBody)
122 } else {
123 res.setHeader('Content-Type', 'application/json')
124 res.end(JSON.stringify(resBody))
125 }
126 } else {
127 res.statusCode = 204
128 res.end()
129 }
130 }
131
132 private async getStatus () {
133 const balanceStatus = await this.getBalanceStatus()
134 const accountStatus = await this.getAccountStatus()
135 return {
136 balances: pluck(balanceStatus['accounts'], 'balance'),
137 connected: pluck(accountStatus['accounts'], 'connected'),
138 localRoutingTable: formatRoutingTableAsJson(this.routingTable)
139 }
140 }
141
142 private async getRoutingStatus () {
143 return this.routeBroadcaster.getStatus()
144 }
145
146 private async getAccountStatus () {
147 return this.accounts.getStatus()
148 }
149
150 private async getBalanceStatus () {
151 const middleware = this.middlewareManager.getMiddleware('balance')
152 if (!middleware) return {}
153 const balanceMiddleware = middleware as BalanceMiddleware
154 return balanceMiddleware.getStatus()
155 }
156
157 private async postBalance (url: string | undefined, _data: object) {
158 try {
159 validateBalanceUpdate(_data)
160 } catch (err) {
161 const firstError = (validateBalanceUpdate.errors &&
162 validateBalanceUpdate.errors[0]) ||
163 { message: 'unknown validation error', dataPath: '' }
164 throw new InvalidJsonBodyError('invalid balance update: error=' + firstError.message + ' dataPath=' + firstError.dataPath, validateBalanceUpdate.errors || [])
165 }
166
167 const data = _data as BalanceUpdate
168 const middleware = this.middlewareManager.getMiddleware('balance')
169 if (!middleware) return
170 const balanceMiddleware = middleware as BalanceMiddleware
171 balanceMiddleware.modifyBalance(data.accountId, data.amountDiff)
172 }
173
174 private getBackendStatus (): Promise<{ [s: string]: any }> {
175 return this.rateBackend.getStatus()
176 }
177
178 private async getStats () {
179 return this.stats.getStatus()
180 }
181
182 private async getAlerts () {
183 const middleware = this.middlewareManager.getMiddleware('alert')
184 if (!middleware) return {}
185 const alertMiddleware = middleware as AlertMiddleware
186 return {
187 alerts: alertMiddleware.getAlerts()
188 }
189 }
190
191 private async deleteAlert (url: string | undefined) {
192 const middleware = this.middlewareManager.getMiddleware('alert')
193 if (!middleware) return {}
194 const alertMiddleware = middleware as AlertMiddleware
195 if (!url) throw new Error('no path on request')
196 const match = /^\/alerts\/(\d+)$/.exec(url.split('?')[0])
197 if (!match) throw new Error('invalid alert id')
198 alertMiddleware.dismissAlert(+match[1])
199 }
200
201 private async getMetrics () {
202 const promRegistry = Prometheus.register
203 const ilpRegistry = this.stats.getRegistry()
204 const mergedRegistry = Prometheus.Registry.merge([promRegistry, ilpRegistry])
205 return mergedRegistry.metrics()
206 }
207
208 private _getPlugin (url: string | undefined) {
209 if (!url) throw new Error('no path on request')
210 const match = /^\/accounts\/([A-Za-z0-9_.\-~]+)$/.exec(url.split('?')[0])
211 if (!match) throw new Error('invalid account.')
212 const account = match[1]
213 const plugin = this.accounts.getPlugin(account)
214 if (!plugin) throw new Error('account does not exist. account=' + account)
215 const info = this.accounts.getInfo(account)
216 return {
217 account,
218 info,
219 plugin
220 }
221 }
222
223 private async getAccountAdminInfo (url: string | undefined) {
224 if (!url) throw new Error('no path on request')
225 const { account, info, plugin } = this._getPlugin(url)
226 if (!plugin.getAdminInfo) throw new Error('plugin has no admin info. account=' + account)
227 return {
228 account,
229 plugin: info.plugin,
230 info: (await plugin.getAdminInfo())
231 }
232 }
233
234 private async sendAccountAdminInfo (url: string | undefined, body?: object) {
235 if (!url) throw new Error('no path on request')
236 if (!body) throw new Error('no json body provided to set admin info.')
237 const { account, info, plugin } = this._getPlugin(url)
238 if (!plugin.sendAdminInfo) throw new Error('plugin does not support sending admin info. account=' + account)
239 return {
240 account,
241 plugin: info.plugin,
242 result: (await plugin.sendAdminInfo(body))
243 }
244 }
245
246 private async addAccount (url: string | undefined, body?: any) {
247 if (!url) throw new Error('no path on request')
248 if (!body) throw new Error('no json body provided to make plugin.')
249
250 const { id, options } = body
251 this.accounts.add(id, options)
252 const plugin = this.accounts.getPlugin(id)
253 await this.middlewareManager.addPlugin(id, plugin)
254
255 await plugin.connect({ timeout: Infinity })
256 this.routeBroadcaster.track(id)
257 this.routeBroadcaster.reloadLocalRoutes()
258 return {
259 plugin: id,
260 connected: plugin.isConnected()
261 }
262 }
263}