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 <pablo@resin.io>
This commit is contained in:
Pablo Carranza Velez 2017-10-30 14:54:02 -07:00
parent b397c998dd
commit 60a4cccfd2
3 changed files with 158 additions and 0 deletions

16
src/lib/iptables.coffee Normal file
View File

@ -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")

73
src/supervisor-api.coffee Normal file
View File

@ -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()

69
src/supervisor.coffee Normal file
View File

@ -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