feat: 🎸 Add initial setup for client daemon integration (#1987)

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11702
This commit is contained in:
Zhihe Li committed 2024-01-16 10:17:45 -05:00
1 parent 25c6ed6100
commit b8b970ce4d
12 files changed
+227 -35

No files matched your search

+4
View File
@@ -179,6 +179,10 @@ export default function initializeMockIPC(server, config) {
closeWindow() {}
toggleFullscreenWindow() {}
stop() {}
checkCommand() {
return faker.system.filePath();
}
addTokenToClientDaemon() {}
}
/**
+9
View File
@@ -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,
});
}
}
/**
+22
View File
@@ -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,
});
}
}
}
@@ -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;
@@ -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 };
@@ -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));
});
},
};
+10 -1
View File
@@ -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
@@ -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
*/
@@ -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) {
@@ -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<void>}
*/
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();
@@ -24,6 +24,7 @@ class MockIPC {
resetClusterUrl() {}
hasMacOSChrome() {}
showWindowActions() {}
addTokenToClientDaemon() {}
}
/**
@@ -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);
});
});