mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-19 05:37:53 +00:00
refactor: Convert supervisor api module to typescript
Change-type: patch Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
parent
3a130f4f9c
commit
9decea1d3b
@ -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",
|
||||
|
@ -271,7 +271,7 @@ export class Config extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
private newUniqueKey(): string {
|
||||
public newUniqueKey(): string {
|
||||
return generateUniqueKey();
|
||||
}
|
||||
|
||||
|
@ -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
196
src/supervisor-api.ts
Normal 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;
|
@ -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'
|
||||
|
||||
|
2
typings/blinking.d.ts
vendored
2
typings/blinking.d.ts
vendored
@ -7,7 +7,7 @@ declare module 'blinking' {
|
||||
}
|
||||
|
||||
interface Blink {
|
||||
start: (pattern: Pattern) => void;
|
||||
start: (pattern?: Pattern) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user