1 | import reduct = require('reduct')
|
2 | import Ajv = require('ajv')
|
3 | import { mapValues as pluck } from 'lodash'
|
4 | import Accounts from './accounts'
|
5 | import Config from './config'
|
6 | import MiddlewareManager from './middleware-manager'
|
7 | import BalanceMiddleware from '../middlewares/balance'
|
8 | import AlertMiddleware from '../middlewares/alert'
|
9 | import RoutingTable from './routing-table'
|
10 | import RouteBroadcaster from './route-broadcaster'
|
11 | import Stats from './stats'
|
12 | import RateBackend from './rate-backend'
|
13 | import { formatRoutingTableAsJson } from '../routing/utils'
|
14 | import { Server, IncomingMessage, ServerResponse } from 'http'
|
15 | import InvalidJsonBodyError from '../errors/invalid-json-body-error'
|
16 | import { BalanceUpdate } from '../schemas/BalanceUpdate'
|
17 | import { create as createLogger } from '../common/log'
|
18 | import * as Prometheus from 'prom-client'
|
19 | const log = createLogger('admin-api')
|
20 | const ajv = new Ajv()
|
21 | const validateBalanceUpdate = ajv.compile(require('../schemas/BalanceUpdate.json'))
|
22 |
|
23 | interface 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 |
|
30 | export 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 | }
|