From 60a4cccfd28e83915d48929d702092ecb9d0baf1 Mon Sep 17 00:00:00 2001 From: Pablo Carranza Velez Date: Mon, 30 Oct 2017 14:54:02 -0700 Subject: [PATCH] Supervisor: Implement a Supervisor class with a SupervisorAPI This will be the top level object in the multicontainer supervisor, using the following objects to perform its duties: * A DB object to manage the sqlite database models * A Config object to manage configuration in sqlite and config.json * An EventTracker to track events and send them to mixpanel * A DeviceState object to manage the device state, including containers, device configuration and dependent devices * An APIBinder object to manage all interactions with the Resin API * The SupervisorAPI, implemented here, which exposes functionality from the other objects over an HTTP API with apikey authentication. We also include an iptables module that the SupervisorAPI will use to only allow traffic from certain interfaces. Signed-off-by: Pablo Carranza Velez --- src/lib/iptables.coffee | 16 +++++++++ src/supervisor-api.coffee | 73 +++++++++++++++++++++++++++++++++++++++ src/supervisor.coffee | 69 ++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/lib/iptables.coffee create mode 100644 src/supervisor-api.coffee create mode 100644 src/supervisor.coffee diff --git a/src/lib/iptables.coffee b/src/lib/iptables.coffee new file mode 100644 index 00000000..52d2206b --- /dev/null +++ b/src/lib/iptables.coffee @@ -0,0 +1,16 @@ +Promise = require 'bluebird' +childProcess = Promise.promisifyAll(require('child_process')) + +checkAndAddIptablesRule = (rule) -> + childProcess.execAsync("iptables -C #{rule}") + .catch -> + childProcess.execAsync("iptables -A #{rule}") + +exports.rejectOnAllInterfacesExcept = (allowedInterfaces, port) -> + Promise.each allowedInterfaces, (iface) -> + checkAndAddIptablesRule("INPUT -p tcp --dport #{port} -i #{iface} -j ACCEPT") + .then -> + checkAndAddIptablesRule("INPUT -p tcp --dport #{port} -j REJECT") + .catch -> + # On systems without REJECT support, fall back to DROP + checkAndAddIptablesRule("INPUT -p tcp --dport #{port} -j DROP") diff --git a/src/supervisor-api.coffee b/src/supervisor-api.coffee new file mode 100644 index 00000000..d329da48 --- /dev/null +++ b/src/supervisor-api.coffee @@ -0,0 +1,73 @@ +_ = require 'lodash' +express = require 'express' +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' ]) + .then (conf) -> + if queryKey? && bufferEq(new Buffer(queryKey), new Buffer(conf.apiSecret)) + next() + else if headerKey? && bufferEq(new Buffer(headerKey), new Buffer(conf.apiSecret)) + next() + else if checkTruthy(conf.localMode) + next() + else + res.sendStatus(401) + .catch (err) -> + # This should never happen... + res.status(503).send('Invalid API key in supervisor') + +module.exports = class SupervisorAPI + constructor: ({ @config, @eventTracker, @routers }) -> + @server = null + @_api = express() + + @_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) + + @_api.post '/v1/tcp-ping', (req, res) => + @config.set({ connectivityCheckEnabled: true }) + res.sendStatus(204) + + @_api.delete '/v1/tcp-ping', (req, res) => + @config.set({ connectivityCheckEnabled: false }) + res.sendStatus(204) + + # Expires the supervisor's API key and generates a new one. + # It also communicates the new key to the Resin 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') + + _.forEach @routers, (router) => + @_api.use(router) + + listen: (allowedInterfaces, port, apiTimeout) => + iptables.rejectOnAllInterfacesExcept(allowedInterfaces, port) + .then => + @server = @_api.listen(port) + @server.timeout = apiTimeout + + stop: -> + @server.close() diff --git a/src/supervisor.coffee b/src/supervisor.coffee new file mode 100644 index 00000000..4995eff3 --- /dev/null +++ b/src/supervisor.coffee @@ -0,0 +1,69 @@ +EventEmitter = require 'events' + +EventTracker = require './event-tracker' +DB = require './db' +Config = require './config' +APIBinder = require './api-binder' +DeviceState = require './device-state' +SupervisorAPI = require './supervisor-api' + +startupConfigFields = [ + 'uuid' + 'listenPort' + 'apiSecret' + 'apiTimeout' + 'offlineMode' + 'mixpanelToken' + 'mixpanelHost' +] + +module.exports = class Supervisor extends EventEmitter + constructor: -> + @db = new DB() + @config = new Config({ @db }) + @eventTracker = new EventTracker() + @deviceState = new DeviceState({ @config, @db, @eventTracker }) + @apiBinder = new APIBinder({ @config, @db, @deviceState, @eventTracker }) + + # FIXME: rearchitect proxyvisor to avoid this circular dependency + # by storing current state and having the APIBinder query and report it / provision devices + @deviceState.applications.proxyvisor.bindToAPI(@apiBinder) + @api = new SupervisorAPI({ @config, @eventTracker, routers: [ @apiBinder.router, @deviceState.router ] }) + + normaliseState: => + @db.init() + .tap => + @config.init() # Ensures uuid, deviceApiKey, apiSecret and logsChannel + .then (needsMigration) => + # We're updating from an older supervisor, so we need to mark images as supervised and remove all containers + if needsMigration + @db.models('legacyData').select() + .then ([ legacyData ]) => + if !legacyData? + console.log('No legacy data found, skipping migration') + return + @deviceState.normaliseLegacy(legacyData) + .then => + @db.finishMigration() + + init: => + @normaliseState() + .then => + @config.getMany(startupConfigFields) + .then (conf) => + @eventTracker.init({ + offlineMode: conf.offlineMode + mixpanelToken: conf.mixpanelToken + mixpanelHost: conf.mixpanelHost + uuid: conf.uuid + }) + .then => + @eventTracker.track('Supervisor start') + @deviceState.init() + .then => + # initialize API + console.log('Starting API server') + @api.listen(@config.constants.allowedInterfaces, conf.listenPort, conf.apiTimeout) + @deviceState.on('shutdown', => @api.stop()) + .then => + @apiBinder.init() # this will first try to provision if it's a new device