@harperfast/harper-pro 5.0.0-alpha.2 → 5.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/core/CONTRIBUTING.md +2 -0
  2. package/core/package.json +2 -2
  3. package/core/resources/DatabaseTransaction.ts +1 -1
  4. package/core/resources/LMDBTransaction.ts +9 -4
  5. package/core/resources/databases.ts +1 -1
  6. package/core/unitTests/resources/permissions.test.js +7 -2
  7. package/core/unitTests/resources/txn-tracking.test.js +10 -4
  8. package/core/unitTests/resources/vectorIndex.test.js +1 -0
  9. package/dist/bin/harper.js +1 -1
  10. package/dist/bin/harper.js.map +1 -1
  11. package/dist/core/resources/DatabaseTransaction.js +1 -1
  12. package/dist/core/resources/DatabaseTransaction.js.map +1 -1
  13. package/dist/core/resources/LMDBTransaction.js +9 -5
  14. package/dist/core/resources/LMDBTransaction.js.map +1 -1
  15. package/dist/core/resources/databases.js +1 -1
  16. package/dist/core/resources/databases.js.map +1 -1
  17. package/dist/licensing/usageLicensing.js +246 -0
  18. package/dist/licensing/usageLicensing.js.map +1 -0
  19. package/dist/licensing/validation.js +149 -0
  20. package/dist/licensing/validation.js.map +1 -0
  21. package/dist/replication/replicator.js +5 -2
  22. package/dist/replication/replicator.js.map +1 -1
  23. package/dist/replication/setNode.js +0 -1
  24. package/dist/replication/setNode.js.map +1 -1
  25. package/dist/security/certificate.js +206 -6
  26. package/dist/security/certificate.js.map +1 -1
  27. package/dist/security/keyService.js +58 -0
  28. package/dist/security/keyService.js.map +1 -0
  29. package/dist/security/sshKeyOperations.js +343 -0
  30. package/dist/security/sshKeyOperations.js.map +1 -0
  31. package/licensing/usageLicensing.ts +262 -0
  32. package/licensing/validation.ts +191 -0
  33. package/npm-shrinkwrap.json +253 -253
  34. package/package.json +3 -2
  35. package/replication/replicator.ts +6 -2
  36. package/replication/setNode.ts +0 -1
  37. package/security/certificate.ts +259 -7
  38. package/security/keyService.ts +74 -0
  39. package/security/sshKeyOperations.ts +405 -0
  40. package/static/defaultConfig.yaml +2 -0
@@ -0,0 +1,405 @@
1
+ import Joi from 'joi';
2
+ import { join, dirname, basename } from 'node:path';
3
+ import { constants, access, readFile, writeFile, unlink, chmod, appendFile, mkdir, readdir } from 'node:fs/promises';
4
+
5
+ import { validateBySchema } from '../core/validation/validationWrapper.js';
6
+ import harperLogger from '../core/utility/logging/harper_logger.js';
7
+ import { ClientError } from '../core/utility/errors/hdbError.js';
8
+ import { CONFIG_PARAMS } from '../core/utility/hdbTerms.ts';
9
+ import env from '../core/utility/environment/environmentManager.js';
10
+ import { replicateOperation } from '../replication/replicator.ts';
11
+
12
+ // SSH key name can only be alphanumeric, dash and underscores
13
+ const SSH_KEY_NAME_REGEX = /^[a-zA-Z0-9-_]+$/;
14
+ const SSH_KEY_NAME_ERROR_MSG = 'SSH key name can only contain alphanumeric, dash and underscore characters';
15
+
16
+ // Helper function to check if a file or directory exists
17
+ const exists = async (path: string): Promise<boolean> =>
18
+ access(path, constants.F_OK)
19
+ .then(() => true)
20
+ .catch(() => false);
21
+
22
+ // Helper function to write a file ensuring the directory exists
23
+ async function writeFileEnsureDir(filePath: string, data: string) {
24
+ await mkdir(dirname(filePath), { recursive: true });
25
+ await writeFile(filePath, data);
26
+ }
27
+
28
+ const addValidationSchema = Joi.object({
29
+ name: Joi.string().pattern(SSH_KEY_NAME_REGEX).required().messages({ 'string.pattern.base': SSH_KEY_NAME_ERROR_MSG }),
30
+ key: Joi.string().required(),
31
+ host: Joi.string().required(),
32
+ hostname: Joi.string().required(),
33
+ known_hosts: Joi.string().optional(),
34
+ });
35
+
36
+ const getSSHKeyValidationSchema = Joi.object({
37
+ name: Joi.string().required(),
38
+ });
39
+
40
+ const updateSSHKeyValidationSchema = Joi.object({
41
+ name: Joi.string().required(),
42
+ key: Joi.string().required(),
43
+ });
44
+
45
+ const deleteSSHKeyValidationSchema = Joi.object({
46
+ name: Joi.string().required(),
47
+ });
48
+
49
+ const setSSHKnownHostsValidationSchema = Joi.object({
50
+ known_hosts: Joi.string().required(),
51
+ });
52
+
53
+ function getSSHPaths(keyName: string | undefined): {
54
+ sshDir: string;
55
+ filePath: string | undefined;
56
+ configFile: string;
57
+ knownHostsFile: string;
58
+ } {
59
+ const rootDir = env.get(CONFIG_PARAMS.ROOTPATH);
60
+ const sshDir = join(rootDir, 'ssh');
61
+ const filePath = keyName ? join(sshDir, keyName + '.key') : undefined;
62
+ const configFile = join(sshDir, 'config');
63
+ const knownHostsFile = join(sshDir, 'known_hosts');
64
+
65
+ return { sshDir, filePath, configFile, knownHostsFile };
66
+ }
67
+
68
+ interface AddSSHKeyRequest {
69
+ name: string;
70
+ key: string;
71
+ host: string;
72
+ hostname: string;
73
+ known_hosts?: string;
74
+ }
75
+
76
+ /**
77
+ * Adds a new SSH key along with its associated SSH config block and optional
78
+ * known_hosts entries. If the hostname is `github.com`, GitHub's public SSH
79
+ * keys are automatically fetched and added to the known_hosts file.
80
+ *
81
+ * @param req - The request object containing the SSH key details.
82
+ * @param req.name - The name of the SSH key to add.
83
+ * @param req.key - The SSH key contents to write to disk.
84
+ * @param req.host - The Host alias to use in the SSH config block.
85
+ * @param req.hostname - The HostName (real hostname) to use in the SSH config block.
86
+ * @param req.known_hosts - Optional known_hosts entries to append to the known_hosts file.
87
+ * @returns An object containing a success message and optional replication results.
88
+ */
89
+ async function addSSHKey(req: AddSSHKeyRequest): Promise<{ message: string; replicated?: unknown[] }> {
90
+ const validation = validateBySchema(req, addValidationSchema);
91
+ if (validation) throw new ClientError(validation.message);
92
+
93
+ const { name, key, host, hostname, known_hosts } = req;
94
+ harperLogger?.trace('adding ssh key', name);
95
+
96
+ const { filePath, configFile, knownHostsFile } = getSSHPaths(name);
97
+
98
+ // Check if the key already exists
99
+ if (await exists(filePath)) {
100
+ throw new ClientError('Key already exists. Use update_ssh_key or delete_ssh_key and then add_ssh_key');
101
+ }
102
+
103
+ // Create the key file
104
+ await writeFileEnsureDir(filePath, key);
105
+ await chmod(filePath, 0o600);
106
+
107
+ // Build the config block string
108
+ const configBlock = `#${name}
109
+ Host ${host}
110
+ HostName ${hostname}
111
+ User git
112
+ IdentityFile ${filePath}
113
+ IdentitiesOnly yes`;
114
+
115
+ // If the file already exists, add a new config block, otherwise write the file for the first time
116
+ if (await exists(configFile)) {
117
+ await appendFile(configFile, '\n' + configBlock);
118
+ } else {
119
+ await writeFileEnsureDir(configFile, configBlock);
120
+ }
121
+
122
+ let additionalMessage = '';
123
+
124
+ // Create the known_hosts file and set permissions if missing
125
+ if (!(await exists(knownHostsFile))) {
126
+ await writeFileEnsureDir(knownHostsFile, '');
127
+ await chmod(knownHostsFile, 0o600);
128
+ }
129
+
130
+ // If adding a github.com ssh key download it automatically
131
+ if (hostname === 'github.com') {
132
+ const fileContents: string = await readFile(knownHostsFile, 'utf8');
133
+
134
+ // Check if there's already github.com entries
135
+ if (!fileContents.includes('github.com')) {
136
+ try {
137
+ const response = await fetch('https://api.github.com/meta');
138
+ const respJson = await response.json();
139
+ const sshKeys = respJson['ssh_keys'];
140
+ for (const knownHost of sshKeys) {
141
+ await appendFile(knownHostsFile, 'github.com ' + knownHost + '\n');
142
+ }
143
+ } catch {
144
+ additionalMessage =
145
+ '. Unable to get known hosts from github.com. Set your known hosts manually using set_ssh_known_hosts.';
146
+ }
147
+ }
148
+ }
149
+
150
+ if (known_hosts) {
151
+ await appendFile(knownHostsFile, known_hosts);
152
+ }
153
+ let response = await replicateOperation(req);
154
+ response.message = `Added ssh key: ${name}${additionalMessage}`;
155
+
156
+ return response;
157
+ }
158
+
159
+ /**
160
+ * Retrieves an SSH key by name, along with any associated Host and HostName
161
+ * configuration from the SSH config file.
162
+ *
163
+ * @param req - The request object containing the key name.
164
+ * @param req.name - The name of the SSH key to retrieve.
165
+ * @returns An object containing the key name, the key contents, and optionally
166
+ * the Host and HostName from the SSH config file.
167
+ */
168
+ async function getSSHKey(req: {
169
+ name: string;
170
+ }): Promise<{ name: string; key: string; host?: string; hostname?: string }> {
171
+ const validation = validateBySchema(req, getSSHKeyValidationSchema);
172
+ if (validation) throw new ClientError(validation.message);
173
+
174
+ const { name } = req;
175
+ const { filePath, configFile } = getSSHPaths(name);
176
+
177
+ if (!(await exists(filePath))) {
178
+ throw new ClientError(`SSH key '${name}' does not exist.`);
179
+ }
180
+
181
+ harperLogger?.trace(`getting ssh key`, name, filePath);
182
+
183
+ const key = await readFile(filePath, 'utf8');
184
+ const result: { name: string; key: string; host?: string; hostname?: string } = { name, key };
185
+
186
+ if (await exists(configFile)) {
187
+ const configContents = await readFile(configFile, 'utf8');
188
+ const { host, hostname } = extractMatchingHostAndHostname(configContents, name);
189
+ if (host) result.host = host;
190
+ if (hostname) result.hostname = hostname;
191
+ }
192
+
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Updates an existing SSH key by overwriting the key file with new contents.
198
+ *
199
+ * @param req - The request object containing the updated key details.
200
+ * @param req.name - The name of the SSH key to update.
201
+ * @param req.key - The new SSH key contents to write to disk.
202
+ * @returns An object containing a success message and optional replication results.
203
+ */
204
+ async function updateSSHKey(req: { name: string; key: string }): Promise<{ message: string; replicated?: unknown[] }> {
205
+ const validation = validateBySchema(req, updateSSHKeyValidationSchema);
206
+ if (validation) throw new ClientError(validation.message);
207
+
208
+ const { name, key } = req;
209
+ harperLogger?.trace(`updating ssh key`, name);
210
+
211
+ const { filePath } = getSSHPaths(name);
212
+ if (!(await exists(filePath))) {
213
+ throw new ClientError(`SSH key '${name}' does not exist. Use add_ssh_key to create it.`);
214
+ }
215
+
216
+ await writeFileEnsureDir(filePath, key);
217
+ await chmod(filePath, 0o600);
218
+
219
+ const response = await replicateOperation(req);
220
+ response.message = `Updated ssh key: ${name}`;
221
+ return response;
222
+ }
223
+
224
+ /**
225
+ * Deletes an existing SSH key and removes its associated config block from
226
+ * the SSH config file.
227
+ *
228
+ * @param req - The request object containing the key name.
229
+ * @param req.name - The name of the SSH key to delete.
230
+ * @returns An object containing a success message and optional replication results.
231
+ */
232
+ async function deleteSSHKey(req: { name: string }): Promise<{ message: string; replicated?: unknown[] }> {
233
+ const validation = validateBySchema(req, deleteSSHKeyValidationSchema);
234
+ if (validation) throw new ClientError(validation.message);
235
+
236
+ const { name } = req;
237
+ harperLogger?.trace(`deleting ssh key`, name);
238
+
239
+ const { filePath, configFile } = getSSHPaths(name);
240
+ if (!(await exists(filePath))) {
241
+ throw new ClientError(`SSH key '${name}' does not exist.`);
242
+ }
243
+
244
+ if (await exists(configFile)) {
245
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
246
+ const configBlockRegex = new RegExp(`#${escapedName}[\\S\\s]*?IdentitiesOnly yes`, 'g');
247
+ const fileContents = (await readFile(configFile, 'utf8')).replace(configBlockRegex, '').trim();
248
+ await writeFileEnsureDir(configFile, fileContents);
249
+ }
250
+
251
+ await unlink(filePath);
252
+
253
+ const response = await replicateOperation(req);
254
+ response.message = `Deleted ssh key: ${name}`;
255
+ return response;
256
+ }
257
+
258
+ /**
259
+ * Lists all SSH keys along with their associated Host and HostName
260
+ * configuration from the SSH config file.
261
+ *
262
+ * @returns An array of objects containing the key name and optionally
263
+ * the Host and HostName from the SSH config file.
264
+ */
265
+ async function listSSHKeys(): Promise<{ name: string; host?: string; hostname?: string }[]> {
266
+ const { sshDir, configFile } = getSSHPaths(undefined);
267
+ if (!(await exists(sshDir))) return [];
268
+
269
+ const EXCLUDED_FILES = new Set(['known_hosts', 'config']);
270
+ const configContents: string | null = (await exists(configFile)) ? await readFile(configFile, 'utf8') : null;
271
+ const files: string[] = await readdir(sshDir);
272
+ return files
273
+ .filter((file) => !EXCLUDED_FILES.has(file))
274
+ .map((file) => {
275
+ const name: string = basename(file, '.key');
276
+ const result: { name: string; host?: string; hostname?: string } = { name };
277
+
278
+ if (configContents) {
279
+ const { host, hostname } = extractMatchingHostAndHostname(configContents, name);
280
+ if (host) result.host = host;
281
+ if (hostname) result.hostname = hostname;
282
+ }
283
+
284
+ return result;
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Extracts the Host and HostName values from an SSH config block matching
290
+ * the given key name. Config blocks are identified by a leading comment
291
+ * in the format `#keyName`.
292
+ *
293
+ * @param configContents - The full contents of the SSH config file.
294
+ * @param name - The name of the SSH key whose config block to extract from.
295
+ * @returns An object containing the optional Host and HostName values from
296
+ * the matching config block, or an empty object if no match is found.
297
+ */
298
+ function extractMatchingHostAndHostname(configContents: string, name: string): { host?: string; hostname?: string } {
299
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
300
+ const configBlockRegex = new RegExp(`#${escapedName}[\\S\\s]*?IdentitiesOnly yes`, 'g');
301
+ const match = configContents.match(configBlockRegex);
302
+
303
+ if (!match?.[0]) return {};
304
+
305
+ const configBlock = match[0];
306
+
307
+ const host = configBlock.match(/^Host\s+(.+)$/m)?.[1]?.trim();
308
+ const hostname = configBlock.match(/^\s*HostName\s+(.+)$/m)?.[1]?.trim();
309
+
310
+ return {
311
+ ...(host && { host }),
312
+ ...(hostname && { hostname }),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Overwrites the SSH known_hosts file with the provided entries.
318
+ *
319
+ * @param req - The request object containing the known_hosts entries.
320
+ * @param req.known_hosts - The known_hosts entries to write to the file.
321
+ * @returns An object containing a success message and optional replication results.
322
+ */
323
+ async function setSSHKnownHosts(req: { known_hosts: string }): Promise<{ message: string; replicated?: unknown[] }> {
324
+ const validation = validateBySchema(req, setSSHKnownHostsValidationSchema);
325
+ if (validation) throw new ClientError(validation.message);
326
+
327
+ const { known_hosts } = req;
328
+ harperLogger?.trace(`setting ssh known hosts`);
329
+
330
+ const { knownHostsFile } = getSSHPaths(undefined);
331
+ await writeFileEnsureDir(knownHostsFile, known_hosts);
332
+ await chmod(knownHostsFile, 0o600);
333
+
334
+ const response = await replicateOperation(req);
335
+ response.message = `Known hosts successfully set`;
336
+
337
+ return response;
338
+ }
339
+
340
+ /**
341
+ * Retrieves the contents of the SSH known_hosts file.
342
+ *
343
+ * @returns An object containing the known_hosts file contents,
344
+ * or `null` if the file does not exist.
345
+ */
346
+ async function getSSHKnownHosts(): Promise<{ known_hosts: string | null }> {
347
+ harperLogger?.trace(`getting ssh known hosts`);
348
+ const { knownHostsFile } = getSSHPaths(undefined);
349
+ if (!(await exists(knownHostsFile))) {
350
+ return { known_hosts: null };
351
+ }
352
+
353
+ return { known_hosts: await readFile(knownHostsFile, 'utf8') };
354
+ }
355
+
356
+ // These will register the operations for the operations API. For now the method and schema are ignored,
357
+ // they are there for when build the REST interface for operations API
358
+ server.registerOperation?.({
359
+ name: 'add_ssh_key',
360
+ execute: addSSHKey,
361
+ httpMethod: 'PUT',
362
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
363
+ });
364
+
365
+ server.registerOperation?.({
366
+ name: 'get_ssh_key',
367
+ execute: getSSHKey,
368
+ httpMethod: 'GET',
369
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
370
+ });
371
+
372
+ server.registerOperation?.({
373
+ name: 'update_ssh_key',
374
+ execute: updateSSHKey,
375
+ httpMethod: 'PATCH',
376
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
377
+ });
378
+
379
+ server.registerOperation?.({
380
+ name: 'delete_ssh_key',
381
+ execute: deleteSSHKey,
382
+ httpMethod: 'DELETE',
383
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
384
+ });
385
+
386
+ server.registerOperation?.({
387
+ name: 'list_ssh_keys',
388
+ execute: listSSHKeys,
389
+ httpMethod: 'GET',
390
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
391
+ });
392
+
393
+ server.registerOperation?.({
394
+ name: 'set_ssh_known_hosts',
395
+ execute: setSSHKnownHosts,
396
+ httpMethod: 'PUT',
397
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
398
+ });
399
+
400
+ server.registerOperation?.({
401
+ name: 'get_ssh_known_hosts',
402
+ execute: getSSHKnownHosts,
403
+ httpMethod: 'GET',
404
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
405
+ });
@@ -72,3 +72,5 @@ tls:
72
72
  privateKey: null
73
73
  node:
74
74
  hostname: null
75
+ license:
76
+ region: null