Implement an API for proxy and hostname configuration, and centralize management of config.json

Change-Type: minor
Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
This commit is contained in:
Pablo Carranza Velez 2018-01-15 18:50:03 -03:00
parent 122b55fbe5
commit cff789ebfa
9 changed files with 368 additions and 110 deletions

View File

@ -38,6 +38,10 @@ type LogToDisplayBody struct {
Enable bool Enable bool
} }
type RestartServiceBody struct {
Name string
}
func jsonResponse(writer http.ResponseWriter, response interface{}, status int) { func jsonResponse(writer http.ResponseWriter, response interface{}, status int) {
jsonBody, err := json.Marshal(response) jsonBody, err := json.Marshal(response)
if err != nil { if err != nil {
@ -278,3 +282,29 @@ func LogToDisplayControl(writer http.ResponseWriter, request *http.Request) {
sendResponse(true, "", http.StatusOK) sendResponse(true, "", http.StatusOK)
} }
} }
//RestartService is used to restart a systemd service by name
func RestartService(writer http.ResponseWriter, request *http.Request) {
sendResponse, sendError := responseSenders(writer)
var body RestartServiceBody
if err := parseJSONBody(&body, request); err != nil {
log.Println(err)
sendResponse("Error", err.Error(), http.StatusBadRequest)
return
}
if systemd.Dbus == nil {
sendError(fmt.Errorf("Systemd dbus unavailable, cannot restart service."))
return
}
actionDescr := "Service Restart"
if _, err := systemd.Dbus.RestartUnit(body.Name+".service", "fail", nil); err != nil {
log.Printf("%s: %s\n", actionDescr, err)
sendError(err)
return
}
log.Printf("%sd\n", actionDescr)
sendResponse("OK", "", http.StatusOK)
}

View File

@ -26,6 +26,7 @@ func setupApi(router *mux.Router) {
apiv1.HandleFunc("/shutdown", ShutdownHandler).Methods("POST") apiv1.HandleFunc("/shutdown", ShutdownHandler).Methods("POST")
apiv1.HandleFunc("/vpncontrol", VPNControl).Methods("POST") apiv1.HandleFunc("/vpncontrol", VPNControl).Methods("POST")
apiv1.HandleFunc("/set-log-to-display", LogToDisplayControl).Methods("POST") apiv1.HandleFunc("/set-log-to-display", LogToDisplayControl).Methods("POST")
apiv1.HandleFunc("/restart-service", RestartService).Methods("POST")
} }
func startApi(listenAddress string, router *mux.Router) { func startApi(listenAddress string, router *mux.Router) {

View File

@ -40,6 +40,7 @@
"log-timestamp": "^0.1.2", "log-timestamp": "^0.1.2",
"memoizee": "^0.4.1", "memoizee": "^0.4.1",
"mixpanel": "0.0.20", "mixpanel": "0.0.20",
"mkdirp": "^0.5.1",
"network-checker": "~0.0.5", "network-checker": "~0.0.5",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"null-loader": "^0.1.1", "null-loader": "^0.1.1",
@ -59,4 +60,4 @@
"versionist": "^2.8.0", "versionist": "^2.8.0",
"webpack": "^3.0.0" "webpack": "^3.0.0"
} }
} }

View File

@ -7,6 +7,7 @@ config = require './config'
device = require './device' device = require './device'
_ = require 'lodash' _ = require 'lodash'
proxyvisor = require './proxyvisor' proxyvisor = require './proxyvisor'
hostConfig = require './host-config'
module.exports = (application) -> module.exports = (application) ->
authenticate = (req, res, next) -> authenticate = (req, res, next) ->
@ -208,7 +209,6 @@ module.exports = (application) ->
.catch (err) -> .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') res.status(503).send(err?.message or err or 'Unknown error')
# Expires the supervisor's API key and generates a new one. # Expires the supervisor's API key and generates a new one.
# It also communicates the new key to the Resin API. # It also communicates the new key to the Resin API.
unparsedRouter.post '/v1/regenerate-api-key', (req, res) -> unparsedRouter.post '/v1/regenerate-api-key', (req, res) ->
@ -219,6 +219,20 @@ module.exports = (application) ->
.catch (err) -> .catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error') res.status(503).send(err?.message or err or 'Unknown error')
parsedRouter.get '/v1/device/host-config', (req, res) ->
hostConfig.get()
.then (conf) ->
res.json(conf)
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
parsedRouter.patch '/v1/device/host-config', (req, res) ->
hostConfig.patch(req.body)
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
unparsedRouter.get '/v1/device', (req, res) -> unparsedRouter.get '/v1/device', (req, res) ->
res.json(device.getState()) res.json(device.getState())

View File

@ -5,7 +5,6 @@ deviceRegister = require 'resin-register-device'
{ resinApi, request } = require './request' { resinApi, request } = require './request'
fs = Promise.promisifyAll(require('fs')) fs = Promise.promisifyAll(require('fs'))
config = require './config' config = require './config'
configPath = '/boot/config.json'
appsPath = '/boot/apps.json' appsPath = '/boot/apps.json'
_ = require 'lodash' _ = require 'lodash'
deviceConfig = require './device-config' deviceConfig = require './device-config'
@ -13,23 +12,13 @@ TypedError = require 'typed-error'
osRelease = require './lib/os-release' osRelease = require './lib/os-release'
semver = require 'semver' semver = require 'semver'
semverRegex = require('semver-regex') semverRegex = require('semver-regex')
configJson = require './config-json'
userConfig = {}
DuplicateUuidError = (err) -> _.startsWith(err.message, '"uuid" must be unique') DuplicateUuidError = (err) -> _.startsWith(err.message, '"uuid" must be unique')
exports.ExchangeKeyError = class ExchangeKeyError extends TypedError exports.ExchangeKeyError = class ExchangeKeyError extends TypedError
bootstrapper = {} bootstrapper = {}
writeAndSyncFile = (path, data) ->
fs.openAsync(path, 'w')
.then (fd) ->
fs.writeAsync(fd, data, 0, 'utf8')
.then ->
fs.fsyncAsync(fd)
.then ->
fs.closeAsync(fd)
loadPreloadedApps = -> loadPreloadedApps = ->
devConfig = {} devConfig = {}
knex('app').select() knex('app').select()
@ -37,27 +26,29 @@ loadPreloadedApps = ->
if apps.length > 0 if apps.length > 0
console.log('Preloaded apps already loaded, skipping') console.log('Preloaded apps already loaded, skipping')
return return
fs.readFileAsync(appsPath, 'utf8') configJson.getAll()
.then(JSON.parse) .then (userConfig) ->
.map (app) -> fs.readFileAsync(appsPath, 'utf8')
utils.extendEnvVars(app.env, userConfig.uuid, userConfig.deviceApiKey, app.appId, app.name, app.commit) .then(JSON.parse)
.then (extendedEnv) -> .map (app) ->
app.env = JSON.stringify(extendedEnv) utils.extendEnvVars(app.env, userConfig.uuid, userConfig.deviceApiKey, app.appId, app.name, app.commit)
app.markedForDeletion = false .then (extendedEnv) ->
_.merge(devConfig, app.config) app.env = JSON.stringify(extendedEnv)
app.config = JSON.stringify(app.config) app.markedForDeletion = false
knex('app').insert(app) _.merge(devConfig, app.config)
.then -> app.config = JSON.stringify(app.config)
deviceConfig.set({ targetValues: devConfig }) knex('app').insert(app)
.then ->
deviceConfig.set({ targetValues: devConfig })
.catch (err) -> .catch (err) ->
utils.mixpanelTrack('Loading preloaded apps failed', { error: err }) utils.mixpanelTrack('Loading preloaded apps failed', { error: err })
fetchDevice = (apiKey) -> fetchDevice = (uuid, apiKey) ->
resinApi.get resinApi.get
resource: 'device' resource: 'device'
options: options:
filter: filter:
uuid: userConfig.uuid uuid: uuid
customOptions: customOptions:
apikey: apiKey apikey: apiKey
.get(0) .get(0)
@ -65,33 +56,47 @@ fetchDevice = (apiKey) ->
.timeout(config.apiTimeout) .timeout(config.apiTimeout)
exchangeKey = -> exchangeKey = ->
Promise.try -> configJson.getAll()
# If we have an existing device key we first check if it's valid, because if it is we can just use that .then (userConfig) ->
if userConfig.deviceApiKey? Promise.try ->
fetchDevice(userConfig.deviceApiKey) # If we have an existing device key we first check if it's valid, because if it is we can just use that
.then (device) -> if userConfig.deviceApiKey?
if device? fetchDevice(userConfig.uuid, userConfig.deviceApiKey)
return device
# If it's not valid/doesn't exist then we try to use the user/provisioning api key for the exchange
fetchDevice(userConfig.apiKey)
.then (device) -> .then (device) ->
if not device? if device?
throw new ExchangeKeyError("Couldn't fetch device with provisioning key") return device
# We found the device, we can try to register a working device key for it # If it's not valid/doesn't exist then we try to use the user/provisioning api key for the exchange
userConfig.deviceApiKey ?= deviceRegister.generateUniqueKey() fetchDevice(userConfig.uuid, userConfig.apiKey)
request.postAsync("#{config.apiEndpoint}/api-key/device/#{device.id}/device-key?apikey=#{userConfig.apiKey}", { .then (device) ->
json: true if not device?
body: throw new ExchangeKeyError("Couldn't fetch device with provisioning key")
apiKey: userConfig.deviceApiKey # We found the device, we can try to register a working device key for it
}) Promise.try ->
.spread (res, body) -> if !userConfig.deviceApiKey?
if res.statusCode != 200 deviceApiKey = deviceRegister.generateUniqueKey()
throw new ExchangeKeyError("Couldn't register device key with provisioning key") configJson.set({ deviceApiKey })
.return(device) .return(deviceApiKey)
else
return userConfig.deviceApiKey
.then (deviceApiKey) ->
request.postAsync("#{config.apiEndpoint}/api-key/device/#{device.id}/device-key?apikey=#{userConfig.apiKey}", {
json: true
body:
apiKey: deviceApiKey
})
.spread (res, body) ->
if res.statusCode != 200
throw new ExchangeKeyError("Couldn't register device key with provisioning key")
.return(device)
bootstrap = -> bootstrap = ->
Promise.try -> configJson.get('deviceType')
userConfig.deviceType ?= 'raspberry-pi' .then (deviceType) ->
if !deviceType?
configJson.set(deviceType: 'raspberry-pi')
.then ->
configJson.getAll()
.then (userConfig) ->
if userConfig.registered_at? if userConfig.registered_at?
return userConfig return userConfig
@ -115,18 +120,22 @@ bootstrap = ->
console.log('Exchanging key failed, having to reregister') console.log('Exchanging key failed, having to reregister')
generateRegistration(true) generateRegistration(true)
.then ({ id }) -> .then ({ id }) ->
userConfig.registered_at = Date.now() toUpdate = {}
userConfig.deviceId = id toDelete = []
if !userConfig.registered_at?
toUpdate.registered_at = Date.now()
toUpdate.deviceId = id
osRelease.getOSVersion(config.hostOSVersionPath) osRelease.getOSVersion(config.hostOSVersionPath)
.then (osVersion) -> .then (osVersion) ->
# Delete the provisioning key now, only if the OS supports it # Delete the provisioning key now, only if the OS supports it
hasSupport = hasDeviceApiKeySupport(osVersion) hasSupport = hasDeviceApiKeySupport(osVersion)
if hasSupport if hasSupport
delete userConfig.apiKey toDelete.push('apiKey')
else else
userConfig.apiKey = userConfig.deviceApiKey toUpdate.apiKey = userConfig.deviceApiKey
writeAndSyncFile(configPath, JSON.stringify(userConfig)) configJson.set(toUpdate, toDelete)
.return(userConfig) .then ->
configJson.getAll()
.then (userConfig) -> .then (userConfig) ->
console.log('Finishing bootstrapping') console.log('Finishing bootstrapping')
knex('config').whereIn('key', ['uuid', 'apiKey', 'username', 'userId', 'version']).delete() knex('config').whereIn('key', ['uuid', 'apiKey', 'username', 'userId', 'version']).delete()
@ -144,20 +153,18 @@ bootstrap = ->
.tap -> .tap ->
bootstrapper.doneBootstrapping() bootstrapper.doneBootstrapping()
readConfig = ->
fs.readFileAsync(configPath, 'utf8')
.then(JSON.parse)
generateRegistration = (forceReregister = false) -> generateRegistration = (forceReregister = false) ->
Promise.try -> Promise.try ->
if forceReregister if forceReregister
userConfig.uuid = deviceRegister.generateUniqueKey() configJson.set({ uuid: deviceRegister.generateUniqueKey(), deviceApiKey: deviceRegister.generateUniqueKey() })
userConfig.deviceApiKey = deviceRegister.generateUniqueKey()
else else
userConfig.uuid ?= deviceRegister.generateUniqueKey() configJson.getAll()
userConfig.deviceApiKey ?= deviceRegister.generateUniqueKey() .then ({ uuid, deviceApiKey }) ->
writeAndSyncFile(configPath, JSON.stringify(userConfig)) uuid ?= deviceRegister.generateUniqueKey()
.return(userConfig.uuid) deviceApiKey ?= deviceRegister.generateUniqueKey()
configJson.set({ uuid, deviceApiKey })
.then ->
configJson.get('uuid')
.catch (err) -> .catch (err) ->
console.log('Error generating and saving UUID: ', err) console.log('Error generating and saving UUID: ', err)
Promise.delay(config.bootstrapRetryDelay) Promise.delay(config.bootstrapRetryDelay)
@ -186,23 +193,27 @@ exchangeKeyAndUpdateConfig = ->
# Otherwise VPN and other host services that use an API key will break. # Otherwise VPN and other host services that use an API key will break.
# #
# In other cases, we make the apiKey equal the deviceApiKey instead. # In other cases, we make the apiKey equal the deviceApiKey instead.
osRelease.getOSVersion(config.hostOSVersionPath) Promise.join(
.then (osVersion) -> configJson.getAll()
hasSupport = hasDeviceApiKeySupport(osVersion) osRelease.getOSVersion(config.hostOSVersionPath)
if hasSupport or userConfig.apiKey != userConfig.deviceApiKey (userConfig, osVersion) ->
console.log('Attempting key exchange') hasSupport = hasDeviceApiKeySupport(osVersion)
exchangeKey() if hasSupport or userConfig.apiKey != userConfig.deviceApiKey
.then -> console.log('Attempting key exchange')
console.log('Key exchange succeeded, starting to use deviceApiKey') exchangeKey()
if hasSupport .then ->
delete userConfig.apiKey configJson.get('deviceApiKey')
else .then (deviceApiKey) ->
userConfig.apiKey = userConfig.deviceApiKey console.log('Key exchange succeeded, starting to use deviceApiKey')
utils.setConfig('deviceApiKey', userConfig.deviceApiKey) utils.setConfig('deviceApiKey', deviceApiKey)
.then -> .then ->
utils.setConfig('apiKey', userConfig.deviceApiKey) utils.setConfig('apiKey', deviceApiKey)
.then -> .then ->
writeAndSyncFile(configPath, JSON.stringify(userConfig)) if hasSupport
configJson.set({}, [ 'apiKey' ])
else
configJson.set(apiKey: deviceApiKey)
)
exchangeKeyOrRetry = do -> exchangeKeyOrRetry = do ->
_failedExchanges = 0 _failedExchanges = 0
@ -217,31 +228,34 @@ exchangeKeyOrRetry = do ->
bootstrapper.done = new Promise (resolve) -> bootstrapper.done = new Promise (resolve) ->
bootstrapper.doneBootstrapping = -> bootstrapper.doneBootstrapping = ->
bootstrapper.bootstrapped = true configJson.getAll()
resolve(userConfig) .then (userConfig) ->
# If we're still using an old api key we can try to exchange it for a valid device key bootstrapper.bootstrapped = true
# This will only be the case when the supervisor/OS has been updated. resolve(userConfig)
if userConfig.apiKey? # If we're still using an old api key we can try to exchange it for a valid device key
exchangeKeyOrRetry() # This will only be the case when the supervisor/OS has been updated.
else if userConfig.apiKey?
Promise.join( exchangeKeyOrRetry()
knex('config').select('value').where(key: 'apiKey') else
knex('config').select('value').where(key: 'deviceApiKey') Promise.join(
([ apiKey ], [ deviceApiKey ]) -> knex('config').select('value').where(key: 'apiKey')
if !deviceApiKey?.value knex('config').select('value').where(key: 'deviceApiKey')
# apiKey in the DB is actually the deviceApiKey, but it was ([ apiKey ], [ deviceApiKey ]) ->
# exchanged in a supervisor version that didn't save it to the DB if !deviceApiKey?.value
# (which mainly affects the RESIN_API_KEY env var) # apiKey in the DB is actually the deviceApiKey, but it was
knex('config').insert({ key: 'deviceApiKey', value: apiKey.value }) # exchanged in a supervisor version that didn't save it to the DB
) # (which mainly affects the RESIN_API_KEY env var)
knex('config').insert({ key: 'deviceApiKey', value: apiKey.value })
)
return return
bootstrapper.bootstrapped = false bootstrapper.bootstrapped = false
bootstrapper.startBootstrapping = -> bootstrapper.startBootstrapping = ->
# Load config file # Load config file
readConfig() configJson.init()
.then (configFromFile) -> .then ->
userConfig = configFromFile configJson.getAll()
.then (userConfig) ->
bootstrapper.offlineMode = !Boolean(config.apiEndpoint) or Boolean(userConfig.supervisorOfflineMode) bootstrapper.offlineMode = !Boolean(config.apiEndpoint) or Boolean(userConfig.supervisorOfflineMode)
knex('config').select('value').where(key: 'uuid') knex('config').select('value').where(key: 'uuid')
.then ([ uuid ]) -> .then ([ uuid ]) ->

46
src/config-json.coffee Normal file
View File

@ -0,0 +1,46 @@
Promise = require 'bluebird'
_ = require 'lodash'
configPath = '/boot/config.json'
Lock = require 'rwlock'
fs = Promise.promisifyAll(require('fs'))
{ writeAndSyncFile } = require './lib/fs-utils'
lock = new Lock()
writeLock = Promise.promisify(lock.async.writeLock)
readLock = Promise.promisify(lock.async.readLock)
withWriteLock = do ->
_takeLock = ->
writeLock('config')
.disposer (release) ->
release()
return (func) ->
Promise.using(_takeLock(), func)
withReadLock = do ->
_takeLock = ->
readLock('config')
.disposer (release) ->
release()
return (func) ->
Promise.using(_takeLock(), func)
# write-through cache of the config.json file
userConfig = null
exports.init = ->
fs.readFileAsync(configPath)
.then (conf) ->
userConfig = JSON.parse(conf)
exports.get = (key) ->
withReadLock ->
return userConfig[key]
exports.getAll = ->
withReadLock ->
return _.clone(userConfig)
exports.set = (vals = {}, keysToDelete = []) ->
withWriteLock ->
_.merge(userConfig, vals)
for key in keysToDelete
delete userConfig[key]
writeAndSyncFile(configPath, JSON.stringify(userConfig))

128
src/host-config.coffee Normal file
View File

@ -0,0 +1,128 @@
Promise = require 'bluebird'
_ = require 'lodash'
utils = require './utils'
path = require 'path'
config = require './config'
fs = Promise.promisifyAll(require('fs'))
configJson = require './config-json'
{ writeFileAtomic } = require './lib/fs-utils'
mkdirp = Promise.promisify(require('mkdirp'))
ENOENT = (err) -> err.code is 'ENOENT'
redsocksHeader = '''
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
'''
redsocksFooter = '}\n'
proxyFields = [ 'type', 'ip', 'port', 'login', 'password' ]
proxyBasePath = path.join('/mnt/root', config.bootMountPoint, 'system-proxy')
redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf')
noProxyPath = path.join(proxyBasePath, 'no_proxy')
readProxy = ->
fs.readFileAsync(redsocksConfPath)
.then (redsocksConf) ->
lines = new String(redsocksConf).split('\n')
conf = {}
for line in lines
for proxyField in proxyFields
if proxyField in [ 'login', 'password' ]
m = line.match(new RegExp(proxyField + '\\s*=\\s*\"(.*)\"\\s*;'))
else
m = line.match(new RegExp(proxyField + '\\s*=\\s*([^;\\s]*)\\s*;'))
if m?
conf[proxyField] = m[1]
return conf
.catch ENOENT, ->
return null
.then (conf) ->
if !conf?
return null
else
fs.readFileAsync(noProxyPath)
.then (noProxy) ->
conf.noProxy = new String(noProxy).split('\n')
return conf
.catch ENOENT, ->
return conf
generateRedsocksConfEntries = (conf) ->
val = ''
for field in proxyFields
if conf[field]?
v = conf[field]
if field in [ 'login', 'password' ]
v = "\"#{v}\""
val += "\t#{field} = #{v};\n"
return val
setProxy = (conf) ->
Promise.try ->
if _.isEmpty(conf)
fs.unlinkAsync(redsocksConfPath)
.catch(ENOENT, _.noop)
.then ->
fs.unlinkAsync(noProxyPath)
.catch(ENOENT, _.noop)
else
mkdirp(proxyBasePath)
.then ->
if _.isArray(conf.noProxy)
writeFileAtomic(noProxyPath, conf.noProxy.join('\n'))
.then ->
redsocksConf = ''
redsocksConf += redsocksHeader
redsocksConf += generateRedsocksConfEntries(conf)
redsocksConf += redsocksFooter
writeFileAtomic(redsocksConfPath, redsocksConf)
.then ->
utils.restartSystemdService('resin-proxy-config')
.then ->
utils.restartSystemdService('redsocks')
hostnamePath = '/mnt/root/etc/hostname'
readHostname = ->
fs.readFileAsync(hostnamePath)
.then (hostnameData) ->
return _.trim(new String(hostnameData))
setHostname = (val) ->
configJson.set(hostname: val)
.then ->
utils.restartSystemdService('resin-hostname')
exports.get = ->
Promise.join(
readProxy()
readHostname()
(proxy, hostname) ->
return {
network: {
proxy
hostname
}
}
)
exports.patch = (conf) ->
Promise.try ->
if !_.isUndefined(conf?.network?.proxy)
setProxy(conf.network.proxy)
.then ->
if !_.isUndefined(conf?.network?.hostname)
setHostname(conf.network.hostname)

16
src/lib/fs-utils.coffee Normal file
View File

@ -0,0 +1,16 @@
Promise = require 'bluebird'
fs = Promise.promisifyAll(require('fs'))
exports.writeAndSyncFile = (path, data) ->
fs.openAsync(path, 'w')
.then (fd) ->
fs.writeAsync(fd, data, 0, 'utf8')
.then ->
fs.fsyncAsync(fd)
.then ->
fs.closeAsync(fd)
exports.writeFileAtomic = (path, data) ->
exports.writeAndSyncFile("#{path}.new", data)
.then ->
fs.renameAsync("#{path}.new", path)

View File

@ -236,6 +236,14 @@ exports.vpnControl = (val, logMessage, { initial = false } = {}) ->
return false return false
.catchReturn(false) .catchReturn(false)
exports.restartSystemdService = (serviceName) ->
gosuper.postAsync('/v1/restart-service', { json: true, body: Name: serviceName })
.spread (response, body) ->
if response.statusCode != 200
err = new Error("Error restarting service #{serviceName}: #{response.statusCode} #{body}")
err.statusCode = response.statusCode
throw err
exports.AppNotFoundError = class AppNotFoundError extends TypedError exports.AppNotFoundError = class AppNotFoundError extends TypedError
exports.getKnexApp = (appId, columns) -> exports.getKnexApp = (appId, columns) ->