import { NextFunction, Request, Response } from 'express'; import * as express from 'express'; import { Server } from 'http'; import * as _ from 'lodash'; import * as morgan from 'morgan'; import * as eventTracker from './event-tracker'; import blink = require('./lib/blink'); import log from './lib/supervisor-console'; import * as apiKeys from './lib/api-keys'; import * as deviceState from './device-state'; import { UpdatesLockedError } from './lib/errors'; const expressLogger = morgan( (tokens, req, res) => [ tokens.method(req, res), req.path, tokens.status(req, res), '-', tokens['response-time'](req, res), 'ms', ].join(' '), { stream: { write: (d) => log.api(d.toString().trimRight()) }, }, ); interface SupervisorAPIConstructOpts { routers: express.Router[]; healthchecks: Array<() => Promise>; } interface SupervisorAPIStopOpts { errored: boolean; } export class SupervisorAPI { private routers: express.Router[]; private healthchecks: Array<() => Promise>; private api = express(); private server: Server | null = null; public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) { this.routers = routers; this.healthchecks = healthchecks; this.api.disable('x-powered-by'); this.api.use(expressLogger); this.api.get('/v1/healthy', async (_req, res) => { try { const healths = await Promise.all(this.healthchecks.map((fn) => fn())); if (!_.every(healths)) { log.error('Healthcheck failed'); return res.status(500).send('Unhealthy'); } return res.sendStatus(200); } catch (_e) { log.error('Healthcheck failed'); return res.status(500).send('Unhealthy'); } }); this.api.get('/ping', (_req, res) => res.send('OK')); this.api.use(apiKeys.authMiddleware); this.api.post('/v1/blink', (_req, res) => { 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: apiKeys.AuthorizedRequest, res) => { await deviceState.initialized; await apiKeys.initialized; // check if we're updating the cloud API key const updateCloudKey = req.auth.apiKey === apiKeys.cloudApiKey; // regenerate the key... const newKey = await apiKeys.refreshKey(req.auth.apiKey); // if we need to update the cloud API with our new key if (updateCloudKey) { // report the new key to the cloud API deviceState.reportCurrentState({ api_secret: apiKeys.cloudApiKey, }); } // return the value of the new key to the caller res.status(200).send(newKey); }, ); // And assign all external routers for (const router of this.routers) { this.api.use(router); } // Error handling. const messageFromError = (err?: Error | string | null): string => { let message = 'Unknown error'; if (err != null) { if (_.isError(err) && err.message != null) { message = err.message; } else { message = err as string; } } return message; }; this.api.use( (err: Error, req: Request, res: Response, next: NextFunction) => { if (res.headersSent) { // Error happens while we are writing the response - default handler closes the connection. next(err); return; } // Return 423 Locked when locks as set const code = err instanceof UpdatesLockedError ? 423 : 503; if (code !== 423) { log.error(`Error on ${req.method} ${req.path}: `, err); } res.status(code).send({ status: 'failed', message: messageFromError(err), }); }, ); } public async listen(port: number, apiTimeout: number): Promise { return new Promise((resolve) => { this.server = this.api.listen(port, () => { log.info(`Supervisor API successfully started on port ${port}`); if (this.server) { this.server.timeout = apiTimeout; } return resolve(); }); }); } public async stop(options?: SupervisorAPIStopOpts): Promise { if (this.server != null) { return new Promise((resolve, reject) => { this.server?.close((err: Error) => { if (err) { log.error('Failed to stop Supervisor API'); return reject(err); } options?.errored ? log.error('Stopped Supervisor API') : log.info('Stopped Supervisor API'); return resolve(); }); }); } } } export default SupervisorAPI;