diff --git a/src/nodes/CommandJoin.ts b/src/nodes/CommandJoin.ts new file mode 100644 index 00000000..2ef822e8 --- /dev/null +++ b/src/nodes/CommandJoin.ts @@ -0,0 +1,74 @@ +import type PolykeyClient from 'polykey/PolykeyClient.js'; +import type { Hostname } from 'polykey/network/types.js'; +import type { NodeIdEncoded, NodeAddress } from 'polykey/nodes/types.js'; +import CommandPolykey from '../CommandPolykey.js'; +import * as binUtils from '../utils/index.js'; +import * as binOptions from '../utils/options.js'; +import * as binProcessors from '../utils/processors.js'; + +class CommandJoin extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('join'); + this.description('Join a network'); + this.argument('', 'Name of the network to join'); + this.addOption(binOptions.nodeId); + this.addOption(binOptions.clientHost); + this.addOption(binOptions.clientPort); + this.action(async (network: string, options) => { + const { default: PolykeyClient } = await import( + 'polykey/PolykeyClient.js' + ); + const nodesUtils = await import('polykey/nodes/utils.js'); + const utils = await import('polykey/utils/index.js'); + const clientOptions = await binProcessors.processClientOptions( + options.nodePath, + options.nodeId, + options.clientHost, + options.clientPort, + this.fs, + this.logger.getChild(binProcessors.processClientOptions.name), + ); + const auth = await binProcessors.processAuthentication( + options.passwordFile, + this.fs, + ); + + let pkClient: PolykeyClient; + this.exitHandlers.handlers.push(async () => { + if (pkClient != null) await pkClient.stop(); + }); + try { + pkClient = await PolykeyClient.createPolykeyClient({ + nodeId: clientOptions.nodeId, + host: clientOptions.clientHost, + port: clientOptions.clientPort, + options: { + nodePath: options.nodePath, + }, + logger: this.logger.getChild(PolykeyClient.name), + }); + const seedNodes = await nodesUtils.resolveSeednodes( + network as Hostname, + ); + const initialNodes = Object.entries(seedNodes) as Array< + [NodeIdEncoded, NodeAddress] + >; + await binUtils.retryAuthentication( + (auth) => + pkClient.rpcClient.methods.nodesSyncGraph({ + network, + initialNodes, + metadata: auth, + }), + auth, + ); + process.stdout.write(`Switched to network ${network}`); + } finally { + if (pkClient! != null) await pkClient.stop(); + } + }); + } +} + +export default CommandJoin; diff --git a/src/nodes/CommandNodes.ts b/src/nodes/CommandNodes.ts index 0c3eff4e..e5bc495f 100644 --- a/src/nodes/CommandNodes.ts +++ b/src/nodes/CommandNodes.ts @@ -1,6 +1,7 @@ import CommandAdd from './CommandAdd.js'; import CommandClaim from './CommandClaim.js'; import CommandFind from './CommandFind.js'; +import CommandJoin from './CommandJoin.js'; import CommandPing from './CommandPing.js'; import CommandGetAll from './CommandGetAll.js'; import CommandConnections from './CommandConnections.js'; @@ -14,6 +15,7 @@ class CommandNodes extends CommandPolykey { this.addCommand(new CommandAdd(...args)); this.addCommand(new CommandClaim(...args)); this.addCommand(new CommandFind(...args)); + this.addCommand(new CommandJoin(...args)); this.addCommand(new CommandPing(...args)); this.addCommand(new CommandGetAll(...args)); this.addCommand(new CommandConnections(...args)); diff --git a/tests/nodes/join.test.ts b/tests/nodes/join.test.ts new file mode 100644 index 00000000..9c4ccfb8 --- /dev/null +++ b/tests/nodes/join.test.ts @@ -0,0 +1,125 @@ +import type { NodeId } from 'polykey/ids/types.js'; +import type { SeedNodes } from 'polykey/nodes/types.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; +import { encodeNodeId, decodeNodeId } from 'polykey/nodes/utils.js'; +import { verifyClaimNetworkAuthority } from 'polykey/claims/payloads/claimNetworkAuthority.js'; +import { jest } from '@jest/globals'; +import PolykeyAgent from 'polykey/PolykeyAgent.js'; +import * as keysUtils from 'polykey/keys/utils/index.js'; +import * as testUtils from '../utils/index.js'; + +let seedNodeId: NodeId; +let seedNodeIdEncoded = ''; +let seedNodeHost = ''; +let seedNodePort = 0; +jest.unstable_mockModule('polykey/nodes/utils.js', () => { + return { + __esModule: true, + decodeNodeId, + resolveSeednodes: jest + .fn<() => Promise>() + .mockImplementation(async () => { + const nodes: SeedNodes = {}; + nodes[seedNodeIdEncoded] = [seedNodeHost, seedNodePort]; + return nodes; + }), + }; +}); + +describe('join', () => { + const logger = new Logger('join test', LogLevel.WARN, [new StreamHandler()]); + const password = 'helloworld'; + let dataDir: string; + let nodePath: string; + let polykeyAgent: PolykeyAgent; + let seedNode: PolykeyAgent; + let seedNodeClaimNetworkAuthority; + let networkKeyPair; + let networkNodeId; + let network = 'test.network.com'; + beforeEach(async () => { + dataDir = await fs.promises.mkdtemp( + path.join(globalThis.tmpDir, 'polykey-test-'), + ); + networkKeyPair = keysUtils.generateKeyPair(); + networkNodeId = keysUtils.publicKeyToNodeId(networkKeyPair.publicKey); + network = 'test.network.com'; + nodePath = path.join(dataDir, 'keynode'); + polykeyAgent = await PolykeyAgent.createPolykeyAgent({ + password, + options: { + seedNodes: {}, // Explicitly no seed nodes on startup + nodePath, + agentServiceHost: '127.0.0.1', + clientServiceHost: '127.0.0.1', + keys: { + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + }, + }, + logger, + }); + // Setting up a remote seednode + seedNode = await PolykeyAgent.createPolykeyAgent({ + password, + options: { + nodePath: path.join(dataDir, 'seednode'), + agentServiceHost: '127.0.0.1', + clientServiceHost: '127.0.0.1', + keys: { + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + }, + }, + logger, + }); + [, seedNodeClaimNetworkAuthority] = + await seedNode.nodeManager.createClaimNetworkAuthority( + networkNodeId, + network, + false, + async (claim) => { + claim.signWithPrivateKey(networkKeyPair.privateKey); + return claim; + }, + ); + await seedNode.nodeManager.createSelfSignedClaimNetworkAccess( + seedNodeClaimNetworkAuthority, + ); + await testUtils.nodesConnect(polykeyAgent, seedNode); + seedNodeId = seedNode.keyRing.getNodeId(); + seedNodeHost = seedNode.agentServiceHost; + seedNodePort = seedNode.agentServicePort; + seedNodeIdEncoded = encodeNodeId(seedNodeId); + }); + afterEach(async () => { + jest.restoreAllMocks(); + await polykeyAgent.stop(); + await seedNode.stop(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + test('should connect to a seednode', async () => { + const command = ['nodes', 'join', '-np', nodePath, network]; + const result = await testUtils.pkStdio(command, { + env: { PK_PASSWORD: password }, + cwd: dataDir, + }); + expect(result.exitCode).toBe(0); + + expect(() => + verifyClaimNetworkAuthority( + networkNodeId, + seedNodeId, + network, + seedNodeClaimNetworkAuthority, + ), + ).not.toThrow(); + }); +});