mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-21 06:33:30 +00:00
Revamp/rewrite of supervisor as a docker application
This commit is contained in:
parent
5a071b35c9
commit
de342a9209
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
*.swp
|
*.swp
|
||||||
|
data
|
||||||
|
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# To build the actual image change the source
|
||||||
|
# image to resin/rpi-raspbian:jessie
|
||||||
|
FROM tianon/debian:jessie
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get install -y -q nodejs npm openvpn
|
||||||
|
|
||||||
|
ADD . /supervisor
|
||||||
|
|
||||||
|
WORKDIR /supervisor
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
135
app.coffee
135
app.coffee
@ -1,135 +0,0 @@
|
|||||||
fs = require('fs')
|
|
||||||
express = require('express')
|
|
||||||
async = require('async')
|
|
||||||
bootstrap = require('./bootstrap')
|
|
||||||
state = require('./state')
|
|
||||||
settings = require('./settings')
|
|
||||||
request = require('request')
|
|
||||||
Application = require('./application')
|
|
||||||
|
|
||||||
console.log('Supervisor started..')
|
|
||||||
|
|
||||||
hakiApp = null
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
(callback) ->
|
|
||||||
if state.get('virgin')
|
|
||||||
console.log('Device is virgin. Bootstrapping')
|
|
||||||
handler = (error) ->
|
|
||||||
if error
|
|
||||||
console.log('Bootstrapping failed with error', error)
|
|
||||||
console.log('Trying again in 10s')
|
|
||||||
setTimeout((-> bootstrap(handler)), 10000)
|
|
||||||
else
|
|
||||||
console.log('Bootstrapping successful')
|
|
||||||
state.set('virgin', false)
|
|
||||||
callback()
|
|
||||||
bootstrap(handler)
|
|
||||||
else
|
|
||||||
console.log("Device isn't a virgin")
|
|
||||||
callback()
|
|
||||||
(callback) ->
|
|
||||||
fs.writeFile('/sys/class/leds/led0/trigger', 'none', callback)
|
|
||||||
(callback) ->
|
|
||||||
hakiApp = new Application(state.get('gitUrl'), '/home/haki/hakiapp', 'haki')
|
|
||||||
|
|
||||||
hakiApp.on 'pre-init', ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Initialising'
|
|
||||||
)
|
|
||||||
|
|
||||||
hakiApp.on 'post-init', ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Idle'
|
|
||||||
)
|
|
||||||
|
|
||||||
hakiApp.on 'pre-update', ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Updating'
|
|
||||||
)
|
|
||||||
|
|
||||||
hakiApp.on 'post-update', (hash) ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Idle'
|
|
||||||
commit: state.get('gitHash')
|
|
||||||
)
|
|
||||||
|
|
||||||
hakiApp.on 'start', ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Running'
|
|
||||||
)
|
|
||||||
|
|
||||||
hakiApp.on 'stop', ->
|
|
||||||
request(
|
|
||||||
uri: "#{settings.API_ENDPOINT}/ewa/device?$filter=uuid eq '#{state.get('uuid')}'"
|
|
||||||
method: 'PATCH'
|
|
||||||
json:
|
|
||||||
status: 'Idle'
|
|
||||||
)
|
|
||||||
|
|
||||||
if not state.get('appInitialised')
|
|
||||||
console.log('Initialising app..')
|
|
||||||
hakiApp.init((error) ->
|
|
||||||
if error then return callback(error)
|
|
||||||
state.set('appInitialised', true)
|
|
||||||
callback()
|
|
||||||
)
|
|
||||||
else
|
|
||||||
console.log('App already initialised')
|
|
||||||
callback()
|
|
||||||
(callback) ->
|
|
||||||
console.log('Fetching new code..')
|
|
||||||
hakiApp.update(callback)
|
|
||||||
(callback) ->
|
|
||||||
console.log('Starting the app..')
|
|
||||||
hakiApp.start(callback)
|
|
||||||
]
|
|
||||||
|
|
||||||
async.series(tasks, (error) ->
|
|
||||||
if error
|
|
||||||
console.error(error)
|
|
||||||
else
|
|
||||||
console.log('Everything is fine :)')
|
|
||||||
)
|
|
||||||
|
|
||||||
app = express()
|
|
||||||
|
|
||||||
app.post('/blink', (req, res) ->
|
|
||||||
ledState = 0
|
|
||||||
toggleLed = ->
|
|
||||||
ledState = (ledState + 1) % 2
|
|
||||||
fs.writeFileSync(settings.LED_FILE, ledState)
|
|
||||||
|
|
||||||
interval = setInterval(toggleLed, settings.BLINK_STEP)
|
|
||||||
setTimeout(->
|
|
||||||
clearInterval(interval)
|
|
||||||
fs.writeFileSync(settings.LED_FILE, 0)
|
|
||||||
res.send(200)
|
|
||||||
, 5000)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.post('/update', (req, res) ->
|
|
||||||
hakiApp.update((error) ->
|
|
||||||
if error
|
|
||||||
res.send(500)
|
|
||||||
else
|
|
||||||
res.send(204)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.listen(80)
|
|
@ -1,52 +0,0 @@
|
|||||||
settings = require('./settings')
|
|
||||||
state = require('./state')
|
|
||||||
{exec} = require('child_process')
|
|
||||||
async = require('async')
|
|
||||||
request = require('request')
|
|
||||||
fs = require('fs')
|
|
||||||
|
|
||||||
bootstrapTasks = [
|
|
||||||
# get config from extra partition
|
|
||||||
(callback) ->
|
|
||||||
console.log('Reading the user conf file')
|
|
||||||
try
|
|
||||||
callback(null, require('/mnt/config.json'))
|
|
||||||
catch error
|
|
||||||
callback(error)
|
|
||||||
# bootstrapping
|
|
||||||
(config, callback) ->
|
|
||||||
console.log('Got user', config.username)
|
|
||||||
console.log('Posting to the API')
|
|
||||||
request.post("#{settings.API_ENDPOINT}/associate", {
|
|
||||||
json:
|
|
||||||
user: config.id
|
|
||||||
}, (error, response, body) ->
|
|
||||||
if error or response.statusCode is 404
|
|
||||||
return callback('Error associating with user')
|
|
||||||
|
|
||||||
state.set('virgin', false)
|
|
||||||
state.set('uuid', body.uuid)
|
|
||||||
state.set('gitUrl', body.gitUrl)
|
|
||||||
|
|
||||||
vpnConf = fs.readFileSync('/etc/openvpn/client.conf.template', 'utf8')
|
|
||||||
vpnConf += "remote #{body.vpnhost} #{body.vpnport}\n"
|
|
||||||
|
|
||||||
console.log('Configuring VPN')
|
|
||||||
fs.writeFileSync('/etc/openvpn/ca.crt', body.ca)
|
|
||||||
fs.writeFileSync('/etc/openvpn/client.crt', body.cert)
|
|
||||||
fs.writeFileSync('/etc/openvpn/client.key', body.key)
|
|
||||||
fs.writeFileSync('/etc/openvpn/client.conf', vpnConf)
|
|
||||||
|
|
||||||
callback(null)
|
|
||||||
)
|
|
||||||
(callback) ->
|
|
||||||
console.log('Starting VPN client..')
|
|
||||||
exec('systemctl start openvpn@client', callback)
|
|
||||||
|
|
||||||
(stdout, stderr, callback) ->
|
|
||||||
console.log('Enabling VPN client..')
|
|
||||||
exec('systemctl enable openvpn@client', callback)
|
|
||||||
]
|
|
||||||
|
|
||||||
module.exports = (callback) ->
|
|
||||||
async.waterfall(bootstrapTasks, callback)
|
|
@ -1 +0,0 @@
|
|||||||
{"virgin":true}
|
|
10
package.json
10
package.json
@ -1,13 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "resin-supervisor",
|
"name": "resin-supervisor",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/supervisor.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"coffee-script": "~1.6.3",
|
"coffee-script": "~1.6.3",
|
||||||
"async": "~0.2.9",
|
"async": "~0.2.9",
|
||||||
"request": "~2.22.0",
|
"request": "~2.22.0",
|
||||||
"posix": "~1.0.2",
|
"posix": "~1.0.2",
|
||||||
"express": "~3.2.6",
|
"express": "~3.2.6",
|
||||||
"lodash": "~1.3.1"
|
"lodash": "~1.3.1",
|
||||||
|
"csr-gen": "~0.2.1",
|
||||||
|
"dockerode": "~0.2.3",
|
||||||
|
"sqlite3": "~2.1.19",
|
||||||
|
"knex": "~0.5.1",
|
||||||
|
"bluebird": "~0.11.5-0"
|
||||||
},
|
},
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 0.10.x"
|
"node >= 0.10.x"
|
||||||
|
22
src/api.coffee
Normal file
22
src/api.coffee
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
fs = require 'fs'
|
||||||
|
express = require 'express'
|
||||||
|
dockerode = require 'dockerode'
|
||||||
|
|
||||||
|
api = express()
|
||||||
|
|
||||||
|
LED_FILE = '/sys/class/leds/led0/brightness'
|
||||||
|
blink = (ms = 200, callback) ->
|
||||||
|
fs.writeFileSync(LED_FILE, 1)
|
||||||
|
setTimeout(-> fs.writeFile(LED_FILE, 0, callback), ms)
|
||||||
|
|
||||||
|
api.post('/blink', (req, res) ->
|
||||||
|
interval = setInterval(blink, 400)
|
||||||
|
setTimeout(-> clearInterval(interval), 5000)
|
||||||
|
res.send(200)
|
||||||
|
)
|
||||||
|
|
||||||
|
api.post('/update', (req, res) ->
|
||||||
|
console.log('TODO: Update the application')
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = api
|
42
src/app.coffee
Normal file
42
src/app.coffee
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
knex = require './db'
|
||||||
|
Promise = require 'bluebird'
|
||||||
|
fs = Promise.promisifyAll(require('fs'))
|
||||||
|
os = require 'os'
|
||||||
|
api = require './api'
|
||||||
|
utils = require './utils'
|
||||||
|
crypto = require 'crypto'
|
||||||
|
{spawn} = require 'child_process'
|
||||||
|
bootstrap = require './bootstrap'
|
||||||
|
|
||||||
|
console.log('Supervisor started..')
|
||||||
|
|
||||||
|
newUuid = utils.getDeviceUuid()
|
||||||
|
oldUuid = knex('config').select('value').where(key: 'uuid')
|
||||||
|
|
||||||
|
bootstrap = Promise.all([newUuid, oldUuid]).then(([newUuid, [oldUuid]]) ->
|
||||||
|
if newUuid is oldUuid
|
||||||
|
return true
|
||||||
|
|
||||||
|
console.log('New device detected. Bootstrapping..')
|
||||||
|
return bootstrap(newUuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
bootstrap.then(->
|
||||||
|
console.log('Starting OpenVPN..')
|
||||||
|
openvpn = spawn('openvpn', ['client.conf'], cwd: '/supervisor/data')
|
||||||
|
|
||||||
|
# Prefix and log all OpenVPN output
|
||||||
|
openvpn.stdout.on('data', (data) ->
|
||||||
|
prefix = 'OPENVPN: '
|
||||||
|
console.log((prefix + data).trim().replace(/\n/gm, '\n#{prefix}'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefix and log all OpenVPN output
|
||||||
|
openvpn.stderr.on('data', (data) ->
|
||||||
|
prefix = 'OPENVPN: '
|
||||||
|
console.log((prefix + data).trim().replace(/\n/gm, '\n#{prefix}'))
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Start API server')
|
||||||
|
api.listen(80)
|
||||||
|
)
|
57
src/bootstrap.coffee
Normal file
57
src/bootstrap.coffee
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
Promise = require('bluebird')
|
||||||
|
_ = require('lodash')
|
||||||
|
fs = Promise.promisifyAll(require('fs'))
|
||||||
|
url = require('url')
|
||||||
|
knex = require './db'
|
||||||
|
crypto = require('crypto')
|
||||||
|
csrgen = Promise.promisify(require('csr-gen'))
|
||||||
|
request = Promise.promisify(require('request'))
|
||||||
|
|
||||||
|
module.exports = (uuid) ->
|
||||||
|
# Load config file
|
||||||
|
config = fs.readFileAsync('/boot/config.json', 'utf8').then(JSON.parse)
|
||||||
|
|
||||||
|
# Generate SSL certificate
|
||||||
|
keys = csrgen(uuid,
|
||||||
|
company: 'Rulemotion Ltd'
|
||||||
|
csrName: 'client.csr'
|
||||||
|
keyName: 'client.key'
|
||||||
|
outputDir: '/supervisor/data'
|
||||||
|
email: 'vpn@resin.io'
|
||||||
|
read: true
|
||||||
|
country: ''
|
||||||
|
city: ''
|
||||||
|
state: ''
|
||||||
|
division: ''
|
||||||
|
)
|
||||||
|
|
||||||
|
Promise.all([config, keys]).then(([config, keys]) ->
|
||||||
|
console.log('UUID:', uuid)
|
||||||
|
console.log('User ID:', config.userId)
|
||||||
|
console.log('User:', config.username)
|
||||||
|
console.log('API key:', config.apiKey)
|
||||||
|
console.log('CSR :', keys.csr)
|
||||||
|
console.log('Posting to the API..')
|
||||||
|
config.csr = keys.csr
|
||||||
|
config.uuid = uuid
|
||||||
|
return request(
|
||||||
|
method: 'POST'
|
||||||
|
url: url.resolve(process.env.API_ENDPOINT, 'associate')
|
||||||
|
json: config
|
||||||
|
)
|
||||||
|
).spread((response, body) ->
|
||||||
|
if response.statusCode >= 400
|
||||||
|
throw body
|
||||||
|
|
||||||
|
console.log('Configuring VPN..')
|
||||||
|
vpnConf = fs.readFileAsync('/supervisor/src/openvpn.conf.tmpl', 'utf8')
|
||||||
|
.then((tmpl) ->
|
||||||
|
fs.writeFileAsync('/supervisor/data/client.conf', _.template(tmpl)(body))
|
||||||
|
)
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fs.writeFileAsync('/supervisor/data/ca.crt', body.ca)
|
||||||
|
fs.writeFileAsync('/supervisor/data/client.crt', body.cert)
|
||||||
|
vpnConf
|
||||||
|
])
|
||||||
|
).then(knex('config').select('value').where(key: 'uuid')
|
28
src/db.coffee
Normal file
28
src/db.coffee
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
Knex = require('knex')
|
||||||
|
|
||||||
|
knex = Knex.initialize(
|
||||||
|
client: 'sqlite3'
|
||||||
|
connection:
|
||||||
|
filename: '/supervisor/data/database.sqlite'
|
||||||
|
)
|
||||||
|
|
||||||
|
knex.schema.hasTable('config').then((exists) ->
|
||||||
|
if not exists
|
||||||
|
knex.schema.createTable('config', (t) ->
|
||||||
|
t.increments('id').primary()
|
||||||
|
t.string('key')
|
||||||
|
t.string('value')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
knex.schema.hasTable('app').then((exists) ->
|
||||||
|
if not exists
|
||||||
|
knex.schema.createTable('app', (t) ->
|
||||||
|
t.increments('id').primary()
|
||||||
|
t.string('name')
|
||||||
|
t.string('imageId')
|
||||||
|
t.string('status')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = knex
|
16
src/openvpn.conf.tmpl
Normal file
16
src/openvpn.conf.tmpl
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
client
|
||||||
|
remote <%= vpnhost %> <%= vpnport %>
|
||||||
|
resolv-retry infinite
|
||||||
|
|
||||||
|
ca ca.crt
|
||||||
|
cert client.crt
|
||||||
|
key client.key
|
||||||
|
|
||||||
|
comp-lzo
|
||||||
|
dev tun
|
||||||
|
proto tcp
|
||||||
|
nobind
|
||||||
|
|
||||||
|
persist-key
|
||||||
|
persist-tun
|
||||||
|
verb 3
|
@ -1,5 +1,4 @@
|
|||||||
module.exports =
|
module.exports =
|
||||||
STATE_FILE: '/opt/ewa-client-bootstrap/data/state.json'
|
|
||||||
API_ENDPOINT: 'http://haki.io'
|
API_ENDPOINT: 'http://haki.io'
|
||||||
HAKI_PATH: '/home/haki'
|
HAKI_PATH: '/home/haki'
|
||||||
|
|
@ -1,4 +1,2 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
require('coffee-script');
|
require('coffee-script');
|
||||||
require('./app');
|
require('./app');
|
18
src/utils.coffee
Normal file
18
src/utils.coffee
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
Promise = require('bluebird')
|
||||||
|
fs = Promise.promisifyAll(require('fs'))
|
||||||
|
os = require('os')
|
||||||
|
crypto = require('crypto')
|
||||||
|
|
||||||
|
# Parses the output of /proc/cpuinfo to find the "Serial : 710abf21" line
|
||||||
|
# or the hostname if there isn't a serial number (when run in dev mode)
|
||||||
|
# The uuid is the SHA1 hash of that value.
|
||||||
|
exports.getDeviceUuid = ->
|
||||||
|
fs.readFileAsync('/proc/cpuinfo', 'utf8').then((cpuinfo) ->
|
||||||
|
serial = cpuinfo
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) -> line.indexOf('Serial') isnt -1)[0]
|
||||||
|
?.split(':')[1]
|
||||||
|
.trim() or os.hostname()
|
||||||
|
|
||||||
|
return crypto.createHash('sha1').update(serial, 'utf8').digest('hex')
|
||||||
|
)
|
16
state.coffee
16
state.coffee
@ -1,16 +0,0 @@
|
|||||||
settings = require('./settings')
|
|
||||||
fs = require('fs')
|
|
||||||
|
|
||||||
sync = (data) ->
|
|
||||||
fs.writeFileSync(settings.STATE_FILE, JSON.stringify(data))
|
|
||||||
|
|
||||||
if not fs.existsSync(settings.STATE_FILE)
|
|
||||||
sync({})
|
|
||||||
|
|
||||||
state = require(settings.STATE_FILE)
|
|
||||||
|
|
||||||
exports.get = (key) -> state[key]
|
|
||||||
|
|
||||||
exports.set = (key, value) ->
|
|
||||||
state[key] = value
|
|
||||||
sync(state)
|
|
Loading…
Reference in New Issue
Block a user