From 8295858b328d885782c6f0cead44248b1f17fd50 Mon Sep 17 00:00:00 2001 From: Miguel Casqueira Date: Fri, 8 May 2020 16:04:21 -0400 Subject: [PATCH] Added endpoint to check if VPN is connected Change-type: minor Signed-off-by: Miguel Casqueira --- docs/API.md | 23 ++++++ package-lock.json | 82 ++++++++++++++++++++ package.json | 4 +- src/device-api/v2.ts | 23 ++++-- test/21-supervisor-api.spec.ts | 115 ++++++++++++++++++++-------- test/27-supervisor-api-auth.spec.ts | 104 +++++++++---------------- test/data/device-api-responses.json | 18 +++++ test/lib/mocked-device-api.ts | 97 +++++++++++++++++++++++ 8 files changed, 358 insertions(+), 108 deletions(-) create mode 100644 test/data/device-api-responses.json create mode 100644 test/lib/mocked-device-api.ts diff --git a/docs/API.md b/docs/API.md index 0461639d..0ce5097a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1152,6 +1152,29 @@ Response: } ``` +#### Device VPN Information + +Added in supervisor version v11.4.0 + +Retrieve information about the VPN connection running on the device. + +From an application container: + +```sh +$ curl "$BALENA_SUPERVISOR_ADDRESS/v2/device/vpn?apikey=$BALENA_SUPERVISOR_API_KEY" +``` + +Response: +```json +{ + "status": "success", + "vpn": { + "enabled": true, + "connected": true + } +} +``` + ### V2 Utilities #### Cleanup volumes with no references diff --git a/package-lock.json b/package-lock.json index cfb11ea5..04f7b5ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -247,6 +247,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==", + "dev": true + }, "@types/dbus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/dbus/-/dbus-1.0.0.tgz", @@ -523,6 +529,25 @@ "@types/sinon": "*" } }, + "@types/superagent": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.7.tgz", + "integrity": "sha512-JSwNPgRYjIC4pIeOqLwWwfGj6iP1n5NE6kNBEbGx2V8H78xCPwx7QpNp9plaI30+W3cFEzJO7BIIsXE+dbtaGg==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.9.tgz", + "integrity": "sha512-0BTpWWWAO1+uXaP/oA0KW1eOZv4hc0knhrWowV06Gwwz3kqQxNO98fUFM2e15T+PdPRmOouNFrYvaBgdojPJ3g==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, "@types/tar-stream": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.1.0.tgz", @@ -2597,6 +2622,12 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -5142,6 +5173,12 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", + "dev": true + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -10390,6 +10427,51 @@ "integrity": "sha1-JAIuSG878c3KCUKDt2nEctO3KJc=", "dev": true }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "supertest": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz", + "integrity": "sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^3.8.3" + } + }, "supports-color": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", diff --git a/package.json b/package.json index 15da499c..f6e4643b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:debug": "npm run typescript:release && npm run packagejson:copy", "lint": "npm run lint:coffee && npm run lint:typescript", "test": "npm run lint && npm run test-nolint", - "test-nolint": "npm run test:build && mocha", + "test-nolint": "npm run test:build && TEST=1 mocha", "test:build": "npm run typescript:test-build && npm run coffeescript:test && npm run testitems:copy && npm run packagejson:copy", "test:fast": "TEST=1 mocha --opts test/fast-mocha.opts", "test:debug": "npm run test:build && TEST=1 mocha --inspect-brk", @@ -61,6 +61,7 @@ "@types/shell-quote": "^1.6.1", "@types/sinon": "^7.5.2", "@types/sinon-chai": "^3.2.3", + "@types/supertest": "^2.0.9", "@types/tmp": "^0.1.0", "@types/yargs": "^15.0.4", "balena-register-device": "^6.1.1", @@ -114,6 +115,7 @@ "sinon": "^7.5.0", "sinon-chai": "^3.5.0", "strict-event-emitter-types": "^2.0.0", + "supertest": "^4.0.2", "tar-stream": "^2.1.2", "terser-webpack-plugin": "^2.3.5", "tmp": "^0.1.0", diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index 659a2d8a..f577da9c 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -4,19 +4,18 @@ import * as _ from 'lodash'; import { ApplicationManager } from '../application-manager'; import { Service } from '../compose/service'; +import Volume from '../compose/volume'; +import { spawnJournalctl } from '../lib/journald'; import { appNotFoundMessage, serviceNotFoundMessage, v2ServiceEndpointInputErrorMessage, } from '../lib/messages'; -import { doPurge, doRestart, serviceAction } from './common'; - -import Volume from '../compose/volume'; -import { spawnJournalctl } from '../lib/journald'; - import log from '../lib/supervisor-console'; import supervisorVersion = require('../lib/supervisor-version'); import { checkInt, checkTruthy } from '../lib/validation'; +import { isVPNActive } from '../network'; +import { doPurge, doRestart, serviceAction } from './common'; export function createV2Api(router: Router, applications: ApplicationManager) { const { _lockingIfNecessary, deviceState } = applications; @@ -459,6 +458,20 @@ export function createV2Api(router: Router, applications: ApplicationManager) { } }); + router.get('/v2/device/vpn', async (_req, res) => { + const config = await deviceState.deviceConfig.getCurrent(); + // Build VPNInfo + const info = { + enabled: config.SUPERVISOR_VPN_CONTROL === 'true', + connected: await isVPNActive(), + }; + // Return payload + return res.json({ + status: 'success', + vpn: info, + }); + }); + router.get('/v2/cleanup-volumes', async (_req, res) => { const targetState = await applications.getTargetApps(); const referencedVolumes: string[] = []; diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index 8967a68b..d11942e2 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -1,68 +1,115 @@ import { expect } from 'chai'; -import { fs } from 'mz'; import { spy } from 'sinon'; +import * as supertest from 'supertest'; -import Config from '../src/config'; -import Database from '../src/db'; -import EventTracker from '../src/event-tracker'; import Log from '../src/lib/supervisor-console'; import SupervisorAPI from '../src/supervisor-api'; +import sampleResponses = require('./data/device-api-responses.json'); +import mockedAPI = require('./lib/mocked-device-api'); const mockedOptions = { - listenPort: 12345, + listenPort: 54321, timeout: 30000, - dbPath: './test/data/supervisor-api.sqlite', }; +const VALID_SECRET = mockedAPI.DEFAULT_SECRET; const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing describe('SupervisorAPI', () => { - describe('State change logging', () => { - let api: SupervisorAPI; - let db: Database; - let mockedConfig: Config; + let api: SupervisorAPI; + const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`); - before(async () => { - db = new Database({ - databasePath: mockedOptions.dbPath, - }); - await db.init(); - mockedConfig = new Config({ db }); - await mockedConfig.init(); + before(async () => { + // Create test API + api = await mockedAPI.create(); + // Start test API + return api.listen( + ALLOWED_INTERFACES, + mockedOptions.listenPort, + mockedOptions.timeout, + ); + }); + + after(async () => { + try { + await api.stop(); + } catch (e) { + if (e.message !== 'Server is not running.') { + throw e; + } + } + // Remove any test data generated + await mockedAPI.cleanUp(); + }); + + describe('/ping', () => { + it('responds with OK (without auth)', async () => { + await request + .get('/ping') + .set('Accept', 'application/json') + .expect(200); }); + it('responds with OK (with auth)', async () => { + await request + .get('/ping') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${VALID_SECRET}`) + .expect(200); + }); + }); - beforeEach(async () => { - api = new SupervisorAPI({ - config: mockedConfig, - eventTracker: new EventTracker(), - routers: [], - healthchecks: [], + describe.skip('V1 endpoints', () => { + // TODO: add tests for V1 endpoints + }); + + describe('V2 endpoints', () => { + describe('GET /v2/device/vpn', () => { + it('returns information about VPN connection', async () => { + await request + .get('/v2/device/vpn') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${VALID_SECRET}`) + .expect('Content-Type', /json/) + .expect(sampleResponses.V2.GET['/device/vpn'].statusCode) + .then(response => { + expect(response.body).to.deep.equal( + sampleResponses.V2.GET['/device/vpn'].body, + ); + }); }); + }); + // TODO: add tests for rest of V2 endpoints + }); + + describe('State change logging', () => { + before(() => { + // Spy on functions we will be testing spy(Log, 'info'); spy(Log, 'error'); }); - afterEach(async () => { - // @ts-ignore - Log.info.restore(); - // @ts-ignore - Log.error.restore(); + beforeEach(async () => { + // Start each case with API stopped try { await api.stop(); } catch (e) { if (e.message !== 'Server is not running.') { - // Ignore since server is already closed throw e; } } }); after(async () => { - try { - await fs.unlink(mockedOptions.dbPath); - } catch (e) { - /* noop */ - } + // @ts-ignore + Log.info.restore(); + // @ts-ignore + Log.error.restore(); + // Resume API for other test suites + return api.listen( + ALLOWED_INTERFACES, + mockedOptions.listenPort, + mockedOptions.timeout, + ); }); it('logs successful start', async () => { diff --git a/test/27-supervisor-api-auth.spec.ts b/test/27-supervisor-api-auth.spec.ts index 5b23734b..59a6899a 100644 --- a/test/27-supervisor-api-auth.spec.ts +++ b/test/27-supervisor-api-auth.spec.ts @@ -1,40 +1,25 @@ -import { expect } from 'chai'; -import { fs } from 'mz'; -import * as requestLib from 'request'; +import * as supertest from 'supertest'; -import Config from '../src/config'; -import Database from '../src/db'; -import EventTracker from '../src/event-tracker'; import SupervisorAPI from '../src/supervisor-api'; +import mockedAPI = require('./lib/mocked-device-api'); const mockedOptions = { listenPort: 12345, timeout: 30000, - dbPath: './test/data/supervisor-api.sqlite', }; -const VALID_SECRET = 'secure_api_secret'; +const VALID_SECRET = mockedAPI.DEFAULT_SECRET; const INVALID_SECRET = 'bad_api_secret'; const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing describe('SupervisorAPI authentication', () => { let api: SupervisorAPI; + const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`); before(async () => { - const db = new Database({ - databasePath: mockedOptions.dbPath, - }); - await db.init(); - const mockedConfig = new Config({ db }); - await mockedConfig.init(); - // Set apiSecret that we can test with - await mockedConfig.set({ apiSecret: VALID_SECRET }); - api = new SupervisorAPI({ - config: mockedConfig, - eventTracker: new EventTracker(), - routers: [], - healthchecks: [], - }); + // Create test API + api = await mockedAPI.create(); + // Start test API return api.listen( ALLOWED_INTERFACES, mockedOptions.listenPort, @@ -43,36 +28,37 @@ describe('SupervisorAPI authentication', () => { }); after(async () => { - api.stop(); try { - await fs.unlink(mockedOptions.dbPath); + await api.stop(); } catch (e) { - /* noop */ + if (e.message !== 'Server is not running.') { + throw e; + } } + // Remove any test data generated + await mockedAPI.cleanUp(); }); it('finds no apiKey and rejects', async () => { - const response = await postAsync('/v1/blink'); - expect(response.statusCode).to.equal(401); + return request.post('/v1/blink').expect(401); }); it('finds apiKey from query', async () => { - const response = await postAsync(`/v1/blink?apikey=${VALID_SECRET}`); - expect(response.statusCode).to.equal(200); + return request.post(`/v1/blink?apikey=${VALID_SECRET}`).expect(200); }); it('finds apiKey from Authorization header (ApiKey scheme)', async () => { - const response = await postAsync(`/v1/blink`, { - Authorization: `ApiKey ${VALID_SECRET}`, - }); - expect(response.statusCode).to.equal(200); + return request + .post('/v1/blink') + .set('Authorization', `ApiKey ${VALID_SECRET}`) + .expect(200); }); it('finds apiKey from Authorization header (Bearer scheme)', async () => { - const response = await postAsync(`/v1/blink`, { - Authorization: `Bearer ${VALID_SECRET}`, - }); - expect(response.statusCode).to.equal(200); + return request + .post('/v1/blink') + .set('Authorization', `Bearer ${VALID_SECRET}`) + .expect(200); }); it('finds apiKey from Authorization header (case insensitive)', async () => { @@ -87,46 +73,28 @@ describe('SupervisorAPI authentication', () => { 'ApIKeY', ]; for (const scheme of randomCases) { - const response = await postAsync(`/v1/blink`, { - Authorization: `${scheme} ${VALID_SECRET}`, - }); - expect(response.statusCode).to.equal(200); + return request + .post('/v1/blink') + .set('Authorization', `${scheme} ${VALID_SECRET}`) + .expect(200); } }); it('rejects invalid apiKey from query', async () => { - const response = await postAsync(`/v1/blink?apikey=${INVALID_SECRET}`); - expect(response.statusCode).to.equal(401); + return request.post(`/v1/blink?apikey=${INVALID_SECRET}`).expect(401); }); it('rejects invalid apiKey from Authorization header (ApiKey scheme)', async () => { - const response = await postAsync(`/v1/blink`, { - Authorization: `ApiKey ${INVALID_SECRET}`, - }); - expect(response.statusCode).to.equal(401); + return request + .post('/v1/blink') + .set('Authorization', `ApiKey ${INVALID_SECRET}`) + .expect(401); }); it('rejects invalid apiKey from Authorization header (Bearer scheme)', async () => { - const response = await postAsync(`/v1/blink`, { - Authorization: `Bearer ${INVALID_SECRET}`, - }); - expect(response.statusCode).to.equal(401); + return request + .post('/v1/blink') + .set('Authorization', `Bearer ${INVALID_SECRET}`) + .expect(401); }); }); - -function postAsync(path: string, headers = {}): Promise { - return new Promise((resolve, reject) => { - requestLib.post( - { - url: `http://127.0.0.1:${mockedOptions.listenPort}${path}`, - headers, - }, - (error: Error, response: requestLib.Response) => { - if (error) { - reject(error); - } - resolve(response); - }, - ); - }); -} diff --git a/test/data/device-api-responses.json b/test/data/device-api-responses.json new file mode 100644 index 00000000..45897ed3 --- /dev/null +++ b/test/data/device-api-responses.json @@ -0,0 +1,18 @@ +{ + "V1": {}, + "V2": { + "GET": { + "/device/vpn": { + "statusCode": 200, + "body": { + "status": "success", + "vpn": { + "enabled": true, + "connected": false + } + } + } + }, + "POST": {} + } +} \ No newline at end of file diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts new file mode 100644 index 00000000..90481d64 --- /dev/null +++ b/test/lib/mocked-device-api.ts @@ -0,0 +1,97 @@ +import { Router } from 'express'; +import { fs } from 'mz'; + +import { ApplicationManager } from '../../src/application-manager'; +import Config from '../../src/config'; +import Database from '../../src/db'; +import { createV1Api } from '../../src/device-api/v1'; +import { createV2Api } from '../../src/device-api/v2'; +import DeviceState from '../../src/device-state'; +import EventTracker from '../../src/event-tracker'; +import SupervisorAPI from '../../src/supervisor-api'; + +const DB_PATH = './test/data/supervisor-api.sqlite'; +const DEFAULT_SECRET = 'secure_api_secret'; + +async function create(): Promise { + // Get SupervisorAPI construct options + const { db, config, eventTracker, deviceState } = await createAPIOpts(); + // Create ApplicationManager + const appManager = new ApplicationManager({ + db, + config, + eventTracker, + logger: null, + deviceState, + apiBinder: null, + }); + // Create SupervisorAPI + const api = new SupervisorAPI({ + config, + eventTracker, + routers: [buildRoutes(appManager)], + healthchecks: [], + }); + // Return SupervisorAPI that is not listening yet + return api; +} + +async function cleanUp(): Promise { + try { + // clean up test data + await fs.unlink(DB_PATH); + } catch (e) { + /* noop */ + } +} + +async function createAPIOpts(): Promise { + // Create database + const db = new Database({ + databasePath: DB_PATH, + }); + await db.init(); + // Create config + const mockedConfig = new Config({ db }); + // Set testing secret + await mockedConfig.set({ + apiSecret: DEFAULT_SECRET, + }); + await mockedConfig.init(); + // Create EventTracker + const tracker = new EventTracker(); + // Create deviceState + const deviceState = new DeviceState({ + db, + config: mockedConfig, + eventTracker: tracker, + logger: null as any, + apiBinder: null as any, + }); + return { + db, + config: mockedConfig, + eventTracker: tracker, + deviceState, + }; +} + +function buildRoutes(appManager: ApplicationManager): Router { + // Create new Router + const router = Router(); + // Add V1 routes + createV1Api(router, appManager); + // Add V2 routes + createV2Api(router, appManager); + // Return modified Router + return router; +} + +interface SupervisorAPIOpts { + db: Database; + config: Config; + eventTracker: EventTracker; + deviceState: DeviceState; +} + +export = { create, cleanUp, DEFAULT_SECRET };