From b8b970ce4deef7c635f02327cb3b8464fc490997 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Thu, 9 Nov 2023 13:20:20 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Add=20initial=20setup=20?= =?UTF-8?q?for=20client=20daemon=20integration=20(#1987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11702 --- addons/api/mirage/scenarios/ipc.js | 4 + ui/desktop/app/routes/application.js | 9 +++ ui/desktop/app/services/session.js | 22 ++++++ .../src/helpers/net-request-promise.js | 31 -------- .../src/helpers/request-promise.js | 69 ++++++++++++++++++ .../electron-app/src/helpers/spawn-promise.js | 20 ++++- ui/desktop/electron-app/src/index.js | 11 ++- ui/desktop/electron-app/src/ipc/handlers.js | 6 ++ .../src/models/runtime-settings.js | 4 +- .../src/services/client-daemon-manager.js | 73 +++++++++++++++++++ ui/desktop/tests/helpers/window-mock-ipc.js | 1 + .../tests/unit/services/session-test.js | 12 +++ 12 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 ui/desktop/app/services/session.js delete mode 100644 ui/desktop/electron-app/src/helpers/net-request-promise.js create mode 100644 ui/desktop/electron-app/src/helpers/request-promise.js create mode 100644 ui/desktop/electron-app/src/services/client-daemon-manager.js create mode 100644 ui/desktop/tests/unit/services/session-test.js diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index 611f50d47..ee92196f3 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -179,6 +179,10 @@ export default function initializeMockIPC(server, config) { closeWindow() {} toggleFullscreenWindow() {} stop() {} + checkCommand() { + return faker.system.filePath(); + } + addTokenToClientDaemon() {} } /** diff --git a/ui/desktop/app/routes/application.js b/ui/desktop/app/routes/application.js index a5685419e..a59714532 100644 --- a/ui/desktop/app/routes/application.js +++ b/ui/desktop/app/routes/application.js @@ -36,6 +36,15 @@ export default class ApplicationRoute extends Route { const theme = this.session.get('data.theme'); this.toggleTheme(theme); await this.clusterUrl.updateClusterUrl(); + + // Add token to client daemon after a successful authentication restoration + if (this.session.isAuthenticated) { + const sessionData = this.session.data?.authenticated; + this.ipc.invoke('addTokenToClientDaemon', { + tokenId: sessionData?.id, + token: sessionData?.token, + }); + } } /** diff --git a/ui/desktop/app/services/session.js b/ui/desktop/app/services/session.js new file mode 100644 index 000000000..0ecd51584 --- /dev/null +++ b/ui/desktop/app/services/session.js @@ -0,0 +1,22 @@ +import { inject as service } from '@ember/service'; +import BaseSessionService from 'ember-simple-auth/services/session'; + +export default class SessionService extends BaseSessionService { + @service ipc; + + /** + * Extend ember simple auth's handleAuthentication method + * so we can hook in and add the user's token to the client daemon + */ + handleAuthentication() { + super.handleAuthentication(...arguments); + + if (this.session.isAuthenticated) { + const sessionData = this.data?.authenticated; + this.ipc.invoke('addTokenToClientDaemon', { + tokenId: sessionData?.id, + token: sessionData?.token, + }); + } + } +} diff --git a/ui/desktop/electron-app/src/helpers/net-request-promise.js b/ui/desktop/electron-app/src/helpers/net-request-promise.js deleted file mode 100644 index 0079b7d18..000000000 --- a/ui/desktop/electron-app/src/helpers/net-request-promise.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -const { net } = require('electron'); - -const requestTimeoutSeconds = 10; -// Simple promise wrapper around Electron's net.request feature. -// https://www.electronjs.org/docs/api/client-request -const netRequestPromise = (url) => - new Promise((resolve, reject) => { - try { - const request = net.request(url); - // We don't worry about canceling the request timeout timer once a - // request ends because: request.abort() has no effect after a request - // ends and the promise will already have been completed, so a rejection - // has no effect either. - const requestTimeout = setTimeout(() => { - request.abort(); - reject(new Error('Request timeout')); - }, requestTimeoutSeconds * 1000); - request.on('error', reject); - request.on('response', resolve); - request.end(); - } catch (e) { - reject(e); - } - }); - -module.exports = netRequestPromise; diff --git a/ui/desktop/electron-app/src/helpers/request-promise.js b/ui/desktop/electron-app/src/helpers/request-promise.js new file mode 100644 index 000000000..7fc0bf4a4 --- /dev/null +++ b/ui/desktop/electron-app/src/helpers/request-promise.js @@ -0,0 +1,69 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +const { net } = require('electron'); +const http = require('http'); + +const requestTimeoutSeconds = 10; +// Simple promise wrapper around Electron's net.request feature. +// https://www.electronjs.org/docs/api/client-request +const netRequest = (url) => + new Promise((resolve, reject) => { + try { + const request = net.request(url); + // We don't worry about canceling the request timeout timer once a + // request ends because: request.abort() has no effect after a request + // ends and the promise will already have been completed, so a rejection + // has no effect either. + const requestTimeout = setTimeout(() => { + request.abort(); + reject(new Error('Request timeout')); + }, requestTimeoutSeconds * 1000); + request.on('error', reject); + request.on('response', resolve); + request.end(); + } catch (e) { + reject(e); + } + }); + +/** + * Promise wrapper around node's http request function. The primary + * use case is to pass in a socket path to be able to make http + * requests to the client daemon. + * @param options + * @param reqBody + * @returns {Promise} + */ +const unixSocketRequest = (options, reqBody) => + new Promise((resolve, reject) => { + const req = http.request(options); + req.once('error', reject); + req.once('response', async (res) => { + const bufs = []; + for await (const buf of res) { + bufs.push(buf); + } + + try { + let parsedResponse; + const response = Buffer.concat(bufs); + if (response.length > 0) { + parsedResponse = JSON.parse(response); + } + + resolve(parsedResponse); + } catch (e) { + reject(e); + } + }); + + if (reqBody) { + req.write(JSON.stringify(reqBody)); + } + req.end(); + }); + +module.exports = { netRequest, unixSocketRequest }; diff --git a/ui/desktop/electron-app/src/helpers/spawn-promise.js b/ui/desktop/electron-app/src/helpers/spawn-promise.js index 92dd1b897..306942070 100644 --- a/ui/desktop/electron-app/src/helpers/spawn-promise.js +++ b/ui/desktop/electron-app/src/helpers/spawn-promise.js @@ -78,7 +78,25 @@ module.exports = { */ spawnSync(command) { const childProcess = spawnSync(path(), command); - const rawOutput = childProcess.output.toString(); + const rawOutput = childProcess.stdout.toString(); return rawOutput; }, + + /** + * Spawn a child process and return a promise. + * Resolves on any output from stdout or stderr. + * @param command + */ + spawn(command) { + return new Promise((resolve, reject) => { + const childProcess = spawn(path(), command); + childProcess.stdout.on('data', (data) => { + resolve({ childProcess, stdout: data.toString() }); + }); + childProcess.stderr.on('data', (data) => { + resolve({ childProcess, stderr: data.toString() }); + }); + childProcess.on('error', (err) => reject(e)); + }); + }, }; diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 9abee38af..dbe5c862e 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -22,9 +22,10 @@ const { } = require('electron'); require('./ipc/handlers.js'); -const runtimeSettings = require('./services/runtime-settings.js'); const { generateCSPHeader } = require('./config/content-security-policy.js'); +const runtimeSettings = require('./services/runtime-settings.js'); const sessionManager = require('./services/session-manager.js'); +const clientDaemonManager = require('./services/client-daemon-manager'); const menu = require('./config/menu.js'); const appUpdater = require('./helpers/app-updater.js'); @@ -233,6 +234,8 @@ app.on('ready', async () => { mainWindow = createWindow(partition, closeWindowCB); } }); + + await clientDaemonManager.start(); }); /** @@ -251,6 +254,12 @@ app.on('before-quit', (event) => { } }); +app.on('quit', () => { + // TODO: Should we check if the desktop client started the daemon? + // If not, we probably shouldn't stop the daemon + clientDaemonManager.stop(); +}); + // Handle an unhandled error in the main thread // // Note that 'uncaughtException' is a crude mechanism for exception handling intended to diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index 7a171ed1e..4eaee0c23 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -14,6 +14,8 @@ const { isLinux, isMac, isWindows } = require('../helpers/platform.js'); const os = require('node:os'); const pty = require('node-pty'); const which = require('which'); +const { unixSocketRequest } = require('../helpers/request-promise'); +const clientDaemonManager = require('../services/client-daemon-manager'); /** * Returns the current runtime clusterUrl, which is used by the main thread to @@ -117,6 +119,10 @@ handle('closeWindow', () => app.quit()); */ handle('checkCommand', async (command) => which(command, { nothrow: true })); +handle('addTokenToClientDaemon', async (data) => + clientDaemonManager.addToken(data) +); + /** * Return an object containing helper fields for determining what OS we're running on */ diff --git a/ui/desktop/electron-app/src/models/runtime-settings.js b/ui/desktop/electron-app/src/models/runtime-settings.js index f24a79300..42574e9cc 100644 --- a/ui/desktop/electron-app/src/models/runtime-settings.js +++ b/ui/desktop/electron-app/src/models/runtime-settings.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -const netRequestPromise = require('../helpers/net-request-promise.js'); +const { netRequest } = require('../helpers/request-promise.js'); // Runtime settings are any configuration, especially user-specified, // set up at runtime. For example, the Boundary clusterUrl is a runtime setting. @@ -36,7 +36,7 @@ class RuntimeSettings { // test to see if the clusterUrl is API-compatible with the desktop client. const scopesEndpoint = `${clusterUrl}/v1/scopes`; try { - const result = await netRequestPromise(scopesEndpoint); + const result = await netRequest(scopesEndpoint); // Redirects (3xx) are handled in the netRequest Promise, so we only // throw an error for 4xx or 5xx responses. if (result.statusCode >= 400) { diff --git a/ui/desktop/electron-app/src/services/client-daemon-manager.js b/ui/desktop/electron-app/src/services/client-daemon-manager.js new file mode 100644 index 000000000..4d59d6943 --- /dev/null +++ b/ui/desktop/electron-app/src/services/client-daemon-manager.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +const { spawnSync, spawn } = require('../helpers/spawn-promise'); +const jsonify = require('../utils/jsonify.js'); +const { unixSocketRequest } = require('../helpers/request-promise'); +const runtimeSettings = require('./runtime-settings'); + +class ClientDaemonManager { + #socketPath; + + get socketPath() { + return this.#socketPath; + } + + /** + * Checks the status of the client daemon. + * @returns {string} + */ + status() { + const daemonStatusCommand = ['daemon', 'status', '-format=json']; + const data = spawnSync(daemonStatusCommand); + + const status = jsonify(data); + this.#socketPath = status.socket_address; + return status; + } + + /** + * Starts the daemon and checks the status to store the socket path. + * @returns {Promise} + */ + async start() { + const startDaemonCommand = ['daemon', 'start']; + await spawn(startDaemonCommand); + this.status(); + } + + /** + * Stops the daemon. + */ + stop() { + const stopDaemonCommand = ['daemon', 'stop']; + spawnSync(stopDaemonCommand); + } + + /** + * Makes a request to the socket to add the token to the daemon. + * If the token already exists, this will update the token in the + * client daemon. + * @param token + * @param tokenId + * @returns {Promise} + */ + addToken({ token, tokenId }) { + const postBody = { + auth_token_id: tokenId, + boundary_addr: runtimeSettings.clusterUrl, + auth_token: token, + }; + const request = { + method: 'POST', + path: 'http://internal.boundary.local/v1/tokens', + socketPath: this.#socketPath, + }; + return unixSocketRequest(request, postBody); + } +} + +// Export an instance so we get a singleton +module.exports = new ClientDaemonManager(); diff --git a/ui/desktop/tests/helpers/window-mock-ipc.js b/ui/desktop/tests/helpers/window-mock-ipc.js index ffc019230..2fa46a246 100644 --- a/ui/desktop/tests/helpers/window-mock-ipc.js +++ b/ui/desktop/tests/helpers/window-mock-ipc.js @@ -24,6 +24,7 @@ class MockIPC { resetClusterUrl() {} hasMacOSChrome() {} showWindowActions() {} + addTokenToClientDaemon() {} } /** diff --git a/ui/desktop/tests/unit/services/session-test.js b/ui/desktop/tests/unit/services/session-test.js new file mode 100644 index 000000000..93394581b --- /dev/null +++ b/ui/desktop/tests/unit/services/session-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'desktop/tests/helpers'; + +module('Unit | Service | session', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:session'); + assert.ok(service); + }); +});