mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-29 18:18:52 +00:00
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:
parent
b397c998dd
commit
60a4cccfd2
16
src/lib/iptables.coffee
Normal file
16
src/lib/iptables.coffee
Normal 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
73
src/supervisor-api.coffee
Normal 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
69
src/supervisor.coffee
Normal 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
|
Loading…
Reference in New Issue
Block a user