refactor: Convert supervisor api module to typescript

Change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
Cameron Diver 2018-12-24 12:31:35 +00:00
parent 3a130f4f9c
commit 9decea1d3b
No known key found for this signature in database
GPG Key ID: 49690ED87032539F
6 changed files with 200 additions and 120 deletions

View File

@ -39,6 +39,7 @@
"@types/lodash": "^4.14.119",
"@types/memoizee": "^0.4.2",
"@types/mkdirp": "^0.5.2",
"@types/morgan": "^1.7.35",
"@types/mz": "0.0.32",
"@types/node": "^10.12.17",
"@types/request": "^2.48.1",

View File

@ -271,7 +271,7 @@ export class Config extends EventEmitter {
});
}
private newUniqueKey(): string {
public newUniqueKey(): string {
return generateUniqueKey();
}

View File

@ -1,117 +0,0 @@
Promise = require 'bluebird'
express = require 'express'
morgan = require 'morgan'
bufferEq = require 'buffer-equal-constant-time'
blink = require './lib/blink'
iptables = require './lib/iptables'
{ checkTruthy } = require './lib/validation'
authenticate = (config) ->
return (req, res, next) ->
queryKey = req.query.apikey
header = req.get('Authorization') ? ''
match = header.match(/^ApiKey (\w+)$/)
headerKey = match?[1]
config.getMany([ 'apiSecret', 'localMode', 'unmanaged', 'osVariant' ])
.then (conf) ->
needsAuth = if conf.unmanaged
conf.osVariant is 'prod'
else
not conf.localMode
if needsAuth
key = queryKey ? headerKey
if bufferEq(Buffer.from(key), Buffer.from(conf.apiSecret))
next()
else
res.sendStatus(401)
else
next()
.catch (err) ->
res.status(503).send("Unexpected error: #{err}")
expressLogger = morgan (tokens, req, res) -> [
'Supervisor API:'
tokens.method(req, res)
req.path
tokens.status(req, res)
'-'
tokens['response-time'](req, res)
'ms'
].join(' ')
module.exports = class SupervisorAPI
constructor: ({ @config, @eventTracker, @routers, @healthchecks }) ->
@server = null
@_api = express()
@_api.disable('x-powered-by')
@_api.use(expressLogger)
@_api.get '/v1/healthy', (req, res) =>
Promise.map @healthchecks, (fn) ->
fn()
.then (healthy) ->
if !healthy
throw new Error('Unhealthy')
.then ->
res.sendStatus(200)
.catch ->
res.sendStatus(500)
@_api.use(authenticate(@config))
@_api.get '/ping', (req, res) ->
res.send('OK')
@_api.post '/v1/blink', (req, res) =>
@eventTracker.track('Device blink')
blink.pattern.start()
setTimeout(blink.pattern.stop, 15000)
res.sendStatus(200)
# Expires the supervisor's API key and generates a new one.
# It also communicates the new key to the balena API.
@_api.post '/v1/regenerate-api-key', (req, res) =>
@config.newUniqueKey()
.then (secret) =>
@config.set(apiSecret: secret)
.then ->
res.status(200).send(secret)
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
for router in @routers
@_api.use(router)
listen: (allowedInterfaces, port, apiTimeout) =>
@config.get('localMode').then (localMode) =>
@applyListeningRules(checkTruthy(localMode), port, allowedInterfaces)
.then =>
# Monitor the switching of local mode, and change which interfaces will
# be listented to based on that
@config.on 'change', (changedConfig) =>
if changedConfig.localMode?
@applyListeningRules(changedConfig.localMode, port, allowedInterfaces)
.then =>
@server = @_api.listen(port)
@server.timeout = apiTimeout
applyListeningRules: (allInterfaces, port, allowedInterfaces) =>
Promise.try ->
if checkTruthy(allInterfaces)
iptables.removeRejections(port).then ->
console.log('Supervisor API listening on all interfaces')
else
iptables.rejectOnAllInterfacesExcept(allowedInterfaces, port).then ->
console.log('Supervisor API listening on allowed interfaces only')
.catch (e) =>
# If there's an error, stop the supervisor api from answering any endpoints,
# and this will eventually be restarted by the healthcheck
console.log('Error on switching supervisor API listening rules - stopping API.')
console.log(' ', e)
if @server?
@stop()
stop: ->
@server.close()

196
src/supervisor-api.ts Normal file
View File

@ -0,0 +1,196 @@
import * as express from 'express';
import { Server } from 'http';
import * as _ from 'lodash';
import * as morgan from 'morgan';
import Config from './config';
import { EventTracker } from './event-tracker';
import blink = require('./lib/blink');
import * as iptables from './lib/iptables';
import { checkTruthy } from './lib/validation';
function getKeyFromReq(req: express.Request): string | null {
const queryKey = req.query.apikey;
if (queryKey != null) {
return queryKey;
}
const maybeHeaderKey = req.get('Authorization');
if (!maybeHeaderKey) {
return null;
}
const match = maybeHeaderKey.match(/^ApiKey (\w+)$/);
return match != null ? match[1] : null;
}
function authenticate(config: Config): express.RequestHandler {
return async (req, res, next) => {
try {
const conf = await config.getMany([
'apiSecret',
'localMode',
'unmanaged',
'osVariant',
]);
const needsAuth = conf.unmanaged
? conf.osVariant === 'prod'
: !conf.localMode;
if (needsAuth) {
// Only get the key if we need it
const key = getKeyFromReq(req);
if (key && conf.apiSecret && key === conf.apiSecret) {
return next();
} else {
return res.sendStatus(401);
}
} else {
return next();
}
} catch (err) {
res.status(503).send(`Unexpected error: ${err}`);
}
};
}
const expressLogger = morgan((tokens, req, res) =>
[
'Supervisor API:',
tokens.method(req, res),
req.path,
tokens.status(req, res),
'-',
tokens['response-time'](req, res),
'ms',
].join(' '),
);
interface SupervisorAPIConstructOpts {
config: Config;
eventTracker: EventTracker;
routers: express.Router[];
healthchecks: Array<() => Promise<boolean>>;
}
export class SupervisorAPI {
private config: Config;
private eventTracker: EventTracker;
private routers: express.Router[];
private healthchecks: Array<() => Promise<boolean>>;
private api = express();
private server: Server | null = null;
public constructor({
config,
eventTracker,
routers,
healthchecks,
}: SupervisorAPIConstructOpts) {
this.config = config;
this.eventTracker = eventTracker;
this.routers = routers;
this.healthchecks = healthchecks;
this.api.disable('x-powered-by');
this.api.use(expressLogger);
this.api.use(authenticate(this.config));
this.api.get('/v1/healthy', async (_req, res) => {
try {
const healths = await Promise.all(this.healthchecks.map(fn => fn()));
if (!_.every(healths)) {
throw new Error('Unhealthy');
}
return res.sendStatus(200);
} catch (e) {
res.sendStatus(500);
}
});
this.api.get('/ping', (_req, res) => res.send('OK'));
this.api.post('/v1/blink', (_req, res) => {
this.eventTracker.track('Device blink');
blink.pattern.start();
setTimeout(blink.pattern.stop, 15000);
return res.sendStatus(200);
});
// Expires the supervisor's API key and generates a new one.
// It also communicates the new key to the balena API.
this.api.post('/v1/regenerate-api-key', async (_req, res) => {
try {
const secret = await this.config.newUniqueKey();
await this.config.set({ apiSecret: secret });
res.status(200).send(secret);
} catch (e) {
res.status(503).send(e != null ? e.message : e || 'Unknown error');
}
});
// And assign all external routers
for (const router of this.routers) {
this.api.use(router);
}
}
public async listen(
allowedInterfaces: string[],
port: number,
apiTimeout: number,
): Promise<void> {
const localMode = (await this.config.get('localMode')) || false;
await this.applyListeningRules(
checkTruthy(localMode) || false,
port,
allowedInterfaces,
);
// Monitor the switching of local mode, and change which interfaces will
// be listened to based on that
this.config.on('change', (changedConfig: Dictionary<string>) => {
if (changedConfig.localMode != null) {
this.applyListeningRules(
checkTruthy(changedConfig.localMode || false) || false,
port,
allowedInterfaces,
);
}
});
this.server = this.api.listen(port);
this.server.timeout = apiTimeout;
}
private async applyListeningRules(
allInterfaces: boolean,
port: number,
allowedInterfaces: string[],
): Promise<void> {
try {
if (checkTruthy(allInterfaces)) {
await iptables.removeRejections(port);
console.log('Supervisor API listening on all interfaces');
} else {
await iptables.rejectOnAllInterfacesExcept(allowedInterfaces, port);
console.log('Supervisor API listening on allowed interfaces only');
}
} catch (err) {
console.log(
'Error on switching supervisor API listening rules - stopping API.',
);
console.log(' ', err);
this.stop();
}
}
public stop() {
if (this.server != null) {
this.server.close();
}
}
}
export default SupervisorAPI;

View File

@ -5,7 +5,7 @@ EventEmitter = require 'events'
{ Config } = require './config'
APIBinder = require './api-binder'
DeviceState = require './device-state'
SupervisorAPI = require './supervisor-api'
{ SupervisorAPI } = require './supervisor-api'
{ Logger } = require './logger'
{ checkTruthy } = require './lib/validation'

View File

@ -7,7 +7,7 @@ declare module 'blinking' {
}
interface Blink {
start: (pattern: Pattern) => void;
start: (pattern?: Pattern) => void;
stop: () => void;
}