diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7c8ae7..5afd40c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! This project adheres to [Semantic Versioning](http://semver.org/). +## v6.6.0 - 2018-01-18 + +* Implement an API for proxy and hostname configuration, and centralize management of config.json #547 [Pablo Carranza Velez] + ## v6.5.9 - 2018-01-12 * Fix saving deviceApiKey to the DB (to fix the RESIN_API_KEY env var) when updating from some older supervisors #544 [Pablo Carranza Velez] diff --git a/docs/API.md b/docs/API.md index 15631b62..82c54de2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,7 +14,7 @@ The supervisor exposes an HTTP API on port 48484 (`RESIN_SUPERVISOR_PORT`). The full address for the API, i.e. `"http://127.0.0.1:48484"`, is available as `RESIN_SUPERVISOR_ADDRESS`. **Always use these variables when communicating via the API, since address and port could change**. -Alternatively, the Resin API (api.resin.io) has a proxy endpoint at `POST /supervisor/` (where `` is one of the API URLs described below) from which you can send API commands to the supervisor remotely, using your Auth Token instead of your API key. Commands sent through the proxy require an `appId` and/or `deviceId` parameter in the body, and default to POST requests unless you specify a `method` parameter (e.g. "GET"). +Alternatively, the Resin API (api.resin.io) has a proxy endpoint at `POST /supervisor/` (where `` is one of the API URLs described below) from which you can send API commands to the supervisor remotely, using your Auth Token instead of your API key. Commands sent through the proxy can specify either an `appId` to send the request to all devices in an application, or a `deviceId` or `uuid` to send to a particular device. These requests default to POST unless you specify a `method` parameter (e.g. "GET"). In the examples below, we show how to use a uuid to specify a device, but in any of those you can replace `uuid` for a `deviceId` or `appId`. The API is versioned (currently at v1), except for `/ping`. @@ -43,7 +43,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "method": "GET"}' \ + --data '{"uuid": , "method": "GET"}' \ "https://api.resin.io/supervisor/ping" ``` @@ -67,7 +67,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/blink" ``` @@ -100,7 +100,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "data": {"force": true}}' \ + --data '{"uuid": , "data": {"force": true}}' \ "https://api.resin.io/supervisor/v1/update" ``` @@ -134,7 +134,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/reboot" ``` @@ -169,7 +169,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/shutdown" ``` @@ -217,7 +217,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "data": {"appId": }}' \ + --data '{"uuid": , "data": {"appId": }}' \ "https://api.resin.io/supervisor/v1/purge" ``` @@ -260,7 +260,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "data": {"appId": }}' \ + --data '{"uuid": , "data": {"appId": }}' \ "https://api.resin.io/supervisor/v1/restart" ``` @@ -285,7 +285,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/tcp-ping" ``` @@ -310,7 +310,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "method": "DELETE"}' \ + --data '{"uuid": , "method": "DELETE"}' \ "https://api.resin.io/supervisor/v1/tcp-ping" ``` @@ -335,7 +335,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/regenerate-api-key" ``` @@ -374,7 +374,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "method": "GET"}' \ + --data '{"uuid": , "method": "GET"}' \ "https://api.resin.io/supervisor/v1/device" ``` @@ -412,7 +412,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/apps//stop" ``` @@ -446,7 +446,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": }' \ + --data '{"uuid": }' \ "https://api.resin.io/supervisor/v1/apps//start" ``` @@ -480,7 +480,7 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "method": "GET"}' \ + --data '{"uuid": , "method": "GET"}' \ "https://api.resin.io/supervisor/v1/apps/" ``` @@ -507,6 +507,101 @@ Remotely via the API proxy: ```bash $ curl -X POST --header "Content-Type:application/json" \ --header "Authorization: Bearer " \ - --data '{"deviceId": , "appId": , "method": "GET"}' \ + --data '{"uuid": , "method": "GET"}' \ "https://api.resin.io/supervisor/v1/healthy" ``` + +
+ +### PATCH /v1/device/host-config + +Added in supervisor v6.6.0. + +This endpoint allows setting some configuration values for the host OS. Currently it supports +proxy and hostname configuration. + +For proxy configuration, resinOS 2.0.7 and higher provides a transparent proxy redirector (redsocks) that makes all connections be routed to a SOCKS or HTTP proxy. This endpoint allows user applications to modify these proxy settings at runtime. + + +#### Request body + +Is a JSON object with several optional fields. Proxy and hostname configuration go under a "network" key. If "proxy" or "hostname" are not present (undefined), those values will not be modified, so that a request can modify hostname +without changing proxy settings and viceversa. + +```json +{ + "network": { + "proxy": { + "type": "http-connect", + "ip": "myproxy.example.com", + "port": 8123, + "login": "username", + "password": "password", + "noProxy": [ "152.10.30.4", "253.1.1.0/16" ] + }, + "hostname": "mynewhostname" + } +} +``` + +In the proxy settings, `type`, `ip`, `port`, `login` and `password` are the settings for the proxy redirector to +be able to connnect to the proxy, based on how [redsocks.conf](https://github.com/darkk/redsocks/blob/master/redsocks.conf.example) works. `type` can be `socks4`, `socks5`, `http-connect` or `http-relay` (not all proxies are +guaranteed to work, especially if they block connections that the resin services may require). + +Keep in mind that, even if transparent proxy redirection will take effect immediately after the API call (i.e. all new connections will go through the proxy), open connections will not be closed. So, if for example, the device has managed to connect to the resin VPN without the proxy, it will stay connected directly without trying to reconnect through the proxy, unless the connection breaks - any reconnection attempts will then go through the proxy. To force *all* connections to go through the proxy, the best way is to reboot the device (see the /v1/reboot endpoint). In most networks were no connections to the Internet can be made if not through a proxy, this should not be necessary (as there will be no open connections before configuring the proxy settings). + +The "noProxy" setting for the proxy is an optional array of IP addresses/subnets that should not be routed through the +proxy. Keep in mind that local/reserved subnets are already [excluded by resinOS automatically](https://github.com/resin-os/meta-resin/blob/master/meta-resin-common/recipes-connectivity/resin-proxy-config/resin-proxy-config/resin-proxy-config#L48). + +If either "proxy" or "hostname" are null or empty values (i.e. `{}` for proxy or an empty string for hostname), they will be cleared to their default values (i.e. not using a proxy, and a hostname equal to the first 7 characters of the device's uuid, respectively). + +#### Examples: +From the app on the device: +```bash +$ curl -X PATCH --header "Content-Type:application/json" \ + --data '{"network": {"hostname": "newhostname"}}' \ + "$RESIN_SUPERVISOR_ADDRESS/v1/device/host-config?apikey=$RESIN_SUPERVISOR_API_KEY" +``` + +Response: +```none +OK +``` + +Remotely via the API proxy: +```bash +$ curl -X POST --header "Content-Type:application/json" \ + --header "Authorization: Bearer " \ + --data '{"uuid": , "method": "PATCH", "data": {"network": {"hostname": "newhostname"}}}' \ + "https://api.resin.io/supervisor/v1/device/host-config" +``` + +
+ +### GET /v1/device/host-config + +Added in supervisor v6.6.0. + +This endpoint allows reading some configuration values for the host OS, previously set with `PATCH /v1/device/host-config`. Currently it supports +proxy and hostname configuration. + +Please refer to the PATCH endpoint above for details on the behavior and meaning of the fields in the response. + +#### Examples: +From the app on the device: +```bash +$ curl "$RESIN_SUPERVISOR_ADDRESS/v1/device/host-config?apikey=$RESIN_SUPERVISOR_API_KEY" +``` + +Response: +```json +{"network":{"proxy":{"ip":"192.168.0.199","port":"8123","type":"socks5"},"hostname":"27b0fdc"}} +``` + +Remotely via the API proxy: +```bash +$ curl -X POST --header "Content-Type:application/json" \ + --header "Authorization: Bearer " \ + --data '{"uuid": , "method": "GET"}' \ + "https://api.resin.io/supervisor/v1/device/host-config" +``` diff --git a/gosuper/gosuper/api.go b/gosuper/gosuper/api.go index 0527dc8a..abd3fa41 100644 --- a/gosuper/gosuper/api.go +++ b/gosuper/gosuper/api.go @@ -38,6 +38,10 @@ type LogToDisplayBody struct { Enable bool } +type RestartServiceBody struct { + Name string +} + func jsonResponse(writer http.ResponseWriter, response interface{}, status int) { jsonBody, err := json.Marshal(response) if err != nil { @@ -278,3 +282,29 @@ func LogToDisplayControl(writer http.ResponseWriter, request *http.Request) { 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) +} diff --git a/gosuper/gosuper/main.go b/gosuper/gosuper/main.go index e27adc98..579fe2da 100644 --- a/gosuper/gosuper/main.go +++ b/gosuper/gosuper/main.go @@ -26,6 +26,7 @@ func setupApi(router *mux.Router) { apiv1.HandleFunc("/shutdown", ShutdownHandler).Methods("POST") apiv1.HandleFunc("/vpncontrol", VPNControl).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) { diff --git a/package.json b/package.json index 67c9db39..d8f711ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "resin-supervisor", "description": "This is resin.io's Supervisor, a program that runs on IoT devices and has the task of running user Apps (which are Docker containers), and updating them as Resin's API informs it to.", - "version": "6.5.9", + "version": "6.6.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -40,6 +40,7 @@ "log-timestamp": "^0.1.2", "memoizee": "^0.4.1", "mixpanel": "0.0.20", + "mkdirp": "^0.5.1", "network-checker": "~0.0.5", "node-loader": "^0.6.0", "null-loader": "^0.1.1", diff --git a/src/api.coffee b/src/api.coffee index 7810304e..7a25ad66 100644 --- a/src/api.coffee +++ b/src/api.coffee @@ -7,6 +7,7 @@ config = require './config' device = require './device' _ = require 'lodash' proxyvisor = require './proxyvisor' +hostConfig = require './host-config' module.exports = (application) -> authenticate = (req, res, next) -> @@ -208,7 +209,6 @@ module.exports = (application) -> .catch (err) -> res.status(503).send(err?.message or err or 'Unknown error') - # Expires the supervisor's API key and generates a new one. # It also communicates the new key to the Resin API. unparsedRouter.post '/v1/regenerate-api-key', (req, res) -> @@ -219,6 +219,20 @@ module.exports = (application) -> .catch (err) -> 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) -> res.json(device.getState()) diff --git a/src/bootstrap.coffee b/src/bootstrap.coffee index c2943866..14e09f38 100644 --- a/src/bootstrap.coffee +++ b/src/bootstrap.coffee @@ -5,7 +5,6 @@ deviceRegister = require 'resin-register-device' { resinApi, request } = require './request' fs = Promise.promisifyAll(require('fs')) config = require './config' -configPath = '/boot/config.json' appsPath = '/boot/apps.json' _ = require 'lodash' deviceConfig = require './device-config' @@ -13,23 +12,13 @@ TypedError = require 'typed-error' osRelease = require './lib/os-release' semver = require 'semver' semverRegex = require('semver-regex') - -userConfig = {} +configJson = require './config-json' DuplicateUuidError = (err) -> _.startsWith(err.message, '"uuid" must be unique') exports.ExchangeKeyError = class ExchangeKeyError extends TypedError 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 = -> devConfig = {} knex('app').select() @@ -37,27 +26,29 @@ loadPreloadedApps = -> if apps.length > 0 console.log('Preloaded apps already loaded, skipping') return - fs.readFileAsync(appsPath, 'utf8') - .then(JSON.parse) - .map (app) -> - utils.extendEnvVars(app.env, userConfig.uuid, userConfig.deviceApiKey, app.appId, app.name, app.commit) - .then (extendedEnv) -> - app.env = JSON.stringify(extendedEnv) - app.markedForDeletion = false - _.merge(devConfig, app.config) - app.config = JSON.stringify(app.config) - knex('app').insert(app) - .then -> - deviceConfig.set({ targetValues: devConfig }) + configJson.getAll() + .then (userConfig) -> + fs.readFileAsync(appsPath, 'utf8') + .then(JSON.parse) + .map (app) -> + utils.extendEnvVars(app.env, userConfig.uuid, userConfig.deviceApiKey, app.appId, app.name, app.commit) + .then (extendedEnv) -> + app.env = JSON.stringify(extendedEnv) + app.markedForDeletion = false + _.merge(devConfig, app.config) + app.config = JSON.stringify(app.config) + knex('app').insert(app) + .then -> + deviceConfig.set({ targetValues: devConfig }) .catch (err) -> utils.mixpanelTrack('Loading preloaded apps failed', { error: err }) -fetchDevice = (apiKey) -> +fetchDevice = (uuid, apiKey) -> resinApi.get resource: 'device' options: filter: - uuid: userConfig.uuid + uuid: uuid customOptions: apikey: apiKey .get(0) @@ -65,33 +56,47 @@ fetchDevice = (apiKey) -> .timeout(config.apiTimeout) exchangeKey = -> - Promise.try -> - # If we have an existing device key we first check if it's valid, because if it is we can just use that - if userConfig.deviceApiKey? - fetchDevice(userConfig.deviceApiKey) - .then (device) -> - if device? - 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) + configJson.getAll() + .then (userConfig) -> + Promise.try -> + # If we have an existing device key we first check if it's valid, because if it is we can just use that + if userConfig.deviceApiKey? + fetchDevice(userConfig.uuid, userConfig.deviceApiKey) .then (device) -> - if not device? - throw new ExchangeKeyError("Couldn't fetch device with provisioning key") - # We found the device, we can try to register a working device key for it - userConfig.deviceApiKey ?= deviceRegister.generateUniqueKey() - request.postAsync("#{config.apiEndpoint}/api-key/device/#{device.id}/device-key?apikey=#{userConfig.apiKey}", { - json: true - body: - apiKey: userConfig.deviceApiKey - }) - .spread (res, body) -> - if res.statusCode != 200 - throw new ExchangeKeyError("Couldn't register device key with provisioning key") - .return(device) + if device? + 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.uuid, userConfig.apiKey) + .then (device) -> + if not device? + throw new ExchangeKeyError("Couldn't fetch device with provisioning key") + # We found the device, we can try to register a working device key for it + Promise.try -> + if !userConfig.deviceApiKey? + deviceApiKey = deviceRegister.generateUniqueKey() + configJson.set({ deviceApiKey }) + .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 = -> - Promise.try -> - userConfig.deviceType ?= 'raspberry-pi' + configJson.get('deviceType') + .then (deviceType) -> + if !deviceType? + configJson.set(deviceType: 'raspberry-pi') + .then -> + configJson.getAll() + .then (userConfig) -> if userConfig.registered_at? return userConfig @@ -115,18 +120,22 @@ bootstrap = -> console.log('Exchanging key failed, having to reregister') generateRegistration(true) .then ({ id }) -> - userConfig.registered_at = Date.now() - userConfig.deviceId = id + toUpdate = {} + toDelete = [] + if !userConfig.registered_at? + toUpdate.registered_at = Date.now() + toUpdate.deviceId = id osRelease.getOSVersion(config.hostOSVersionPath) - .then (osVersion) -> - # Delete the provisioning key now, only if the OS supports it - hasSupport = hasDeviceApiKeySupport(osVersion) - if hasSupport - delete userConfig.apiKey - else - userConfig.apiKey = userConfig.deviceApiKey - writeAndSyncFile(configPath, JSON.stringify(userConfig)) - .return(userConfig) + .then (osVersion) -> + # Delete the provisioning key now, only if the OS supports it + hasSupport = hasDeviceApiKeySupport(osVersion) + if hasSupport + toDelete.push('apiKey') + else + toUpdate.apiKey = userConfig.deviceApiKey + configJson.set(toUpdate, toDelete) + .then -> + configJson.getAll() .then (userConfig) -> console.log('Finishing bootstrapping') knex('config').whereIn('key', ['uuid', 'apiKey', 'username', 'userId', 'version']).delete() @@ -144,20 +153,18 @@ bootstrap = -> .tap -> bootstrapper.doneBootstrapping() -readConfig = -> - fs.readFileAsync(configPath, 'utf8') - .then(JSON.parse) - generateRegistration = (forceReregister = false) -> Promise.try -> if forceReregister - userConfig.uuid = deviceRegister.generateUniqueKey() - userConfig.deviceApiKey = deviceRegister.generateUniqueKey() + configJson.set({ uuid: deviceRegister.generateUniqueKey(), deviceApiKey: deviceRegister.generateUniqueKey() }) else - userConfig.uuid ?= deviceRegister.generateUniqueKey() - userConfig.deviceApiKey ?= deviceRegister.generateUniqueKey() - writeAndSyncFile(configPath, JSON.stringify(userConfig)) - .return(userConfig.uuid) + configJson.getAll() + .then ({ uuid, deviceApiKey }) -> + uuid ?= deviceRegister.generateUniqueKey() + deviceApiKey ?= deviceRegister.generateUniqueKey() + configJson.set({ uuid, deviceApiKey }) + .then -> + configJson.get('uuid') .catch (err) -> console.log('Error generating and saving UUID: ', err) Promise.delay(config.bootstrapRetryDelay) @@ -186,23 +193,27 @@ exchangeKeyAndUpdateConfig = -> # Otherwise VPN and other host services that use an API key will break. # # In other cases, we make the apiKey equal the deviceApiKey instead. - osRelease.getOSVersion(config.hostOSVersionPath) - .then (osVersion) -> - hasSupport = hasDeviceApiKeySupport(osVersion) - if hasSupport or userConfig.apiKey != userConfig.deviceApiKey - console.log('Attempting key exchange') - exchangeKey() - .then -> - console.log('Key exchange succeeded, starting to use deviceApiKey') - if hasSupport - delete userConfig.apiKey - else - userConfig.apiKey = userConfig.deviceApiKey - utils.setConfig('deviceApiKey', userConfig.deviceApiKey) - .then -> - utils.setConfig('apiKey', userConfig.deviceApiKey) - .then -> - writeAndSyncFile(configPath, JSON.stringify(userConfig)) + Promise.join( + configJson.getAll() + osRelease.getOSVersion(config.hostOSVersionPath) + (userConfig, osVersion) -> + hasSupport = hasDeviceApiKeySupport(osVersion) + if hasSupport or userConfig.apiKey != userConfig.deviceApiKey + console.log('Attempting key exchange') + exchangeKey() + .then -> + configJson.get('deviceApiKey') + .then (deviceApiKey) -> + console.log('Key exchange succeeded, starting to use deviceApiKey') + utils.setConfig('deviceApiKey', deviceApiKey) + .then -> + utils.setConfig('apiKey', deviceApiKey) + .then -> + if hasSupport + configJson.set({}, [ 'apiKey' ]) + else + configJson.set(apiKey: deviceApiKey) + ) exchangeKeyOrRetry = do -> _failedExchanges = 0 @@ -217,31 +228,34 @@ exchangeKeyOrRetry = do -> bootstrapper.done = new Promise (resolve) -> bootstrapper.doneBootstrapping = -> - bootstrapper.bootstrapped = true - resolve(userConfig) - # If we're still using an old api key we can try to exchange it for a valid device key - # This will only be the case when the supervisor/OS has been updated. - if userConfig.apiKey? - exchangeKeyOrRetry() - else - Promise.join( - knex('config').select('value').where(key: 'apiKey') - knex('config').select('value').where(key: 'deviceApiKey') - ([ apiKey ], [ deviceApiKey ]) -> - if !deviceApiKey?.value - # apiKey in the DB is actually the deviceApiKey, but it was - # 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 }) - ) + configJson.getAll() + .then (userConfig) -> + bootstrapper.bootstrapped = true + resolve(userConfig) + # If we're still using an old api key we can try to exchange it for a valid device key + # This will only be the case when the supervisor/OS has been updated. + if userConfig.apiKey? + exchangeKeyOrRetry() + else + Promise.join( + knex('config').select('value').where(key: 'apiKey') + knex('config').select('value').where(key: 'deviceApiKey') + ([ apiKey ], [ deviceApiKey ]) -> + if !deviceApiKey?.value + # apiKey in the DB is actually the deviceApiKey, but it was + # 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 bootstrapper.bootstrapped = false bootstrapper.startBootstrapping = -> # Load config file - readConfig() - .then (configFromFile) -> - userConfig = configFromFile + configJson.init() + .then -> + configJson.getAll() + .then (userConfig) -> bootstrapper.offlineMode = !Boolean(config.apiEndpoint) or Boolean(userConfig.supervisorOfflineMode) knex('config').select('value').where(key: 'uuid') .then ([ uuid ]) -> diff --git a/src/config-json.coffee b/src/config-json.coffee new file mode 100644 index 00000000..b1c878b1 --- /dev/null +++ b/src/config-json.coffee @@ -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)) diff --git a/src/host-config.coffee b/src/host-config.coffee new file mode 100644 index 00000000..e6a3d083 --- /dev/null +++ b/src/host-config.coffee @@ -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) diff --git a/src/lib/fs-utils.coffee b/src/lib/fs-utils.coffee new file mode 100644 index 00000000..b05e8bea --- /dev/null +++ b/src/lib/fs-utils.coffee @@ -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) diff --git a/src/utils.coffee b/src/utils.coffee index a9fbc98c..d8ead9dd 100644 --- a/src/utils.coffee +++ b/src/utils.coffee @@ -236,6 +236,14 @@ exports.vpnControl = (val, logMessage, { initial = false } = {}) -> return 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.getKnexApp = (appId, columns) ->