From ab2cc9c1bfd96a8812765e60a12a073f8e232c3e Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 30 Jul 2026 18:42:27 +0400 Subject: [PATCH] samoke that connects to the newly created iniversa network --- .idea/misc.xml | 1 - README.md | 9 +- package.json | 1 + scripts/test-network.js | 219 ++++++++++++++++++++++++++++++++++++++ scripts/test-network.sh | 5 + src/index.js | 105 ++++++++++++++++-- src/indigo_demo_2025.json | 49 --------- src/universa.json | 46 ++++++++ 8 files changed, 372 insertions(+), 63 deletions(-) create mode 100644 scripts/test-network.js create mode 100755 scripts/test-network.sh delete mode 100644 src/indigo_demo_2025.json create mode 100644 src/universa.json diff --git a/.idea/misc.xml b/.idea/misc.xml index 11a695e..4519b83 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - {} diff --git a/README.md b/README.md index 7ccfcd8..d943ef1 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,15 @@ Generate a Universa key smoke test: npm start -- --generate-key ``` -Important. ___We miss an actual topology file, so this one is not working yet__. - -Test a Universa network connection with `src/indigo_demo_2025.json`: +Test the Universa network with the topology in `src/universa.json`: ```sh -npm start -- --test-network +npm run test:network ``` +The test verifies each node's signed topology, opens an authenticated session, +and requires a Universa `sping`/`spong` response from a network quorum. + ## Check ```sh diff --git a/package.json b/package.json index ab137ea..6596638 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "start": "node src/index.js", + "test:network": "bash scripts/test-network.sh", "check": "node --check src/index.js" }, "engines": { diff --git a/scripts/test-network.js b/scripts/test-network.js new file mode 100644 index 0000000..36b0028 --- /dev/null +++ b/scripts/test-network.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + Boss, + PrivateKey, + PublicKey, + SymmetricKey, + decode64, + encode64, + randomBytes +} = require('unicrypto'); + +const projectDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const topologyFile = resolve(projectDirectory, 'src/universa.json'); +const cryptoDist = resolve(projectDirectory, 'node_modules/unicrypto/dist'); +const requestTimeoutMs = 5000; +const clientVersion = 3; + +function describeError(error) { + if (error instanceof Error) return error.message; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +async function withWasmLoader(callback) { + const savedFetch = globalThis.fetch; + const savedRead = globalThis.read; + const savedReadBinary = globalThis.readBinary; + + globalThis.fetch = undefined; + globalThis.read = (fileName, binary) => { + const data = readFileSync(resolve(cryptoDist, basename(fileName))); + return binary ? data : data.toString('binary'); + }; + globalThis.readBinary = (fileName) => ( + readFileSync(resolve(cryptoDist, basename(fileName))) + ); + + try { + return await callback(savedFetch); + } finally { + globalThis.fetch = savedFetch; + if (savedRead === undefined) delete globalThis.read; + else globalThis.read = savedRead; + if (savedReadBinary === undefined) delete globalThis.readBinary; + else globalThis.readBinary = savedReadBinary; + } +} + +function loadTopology() { + const topology = JSON.parse(readFileSync(topologyFile, 'utf8')); + const nodes = Array.isArray(topology) ? topology : topology.list; + + if (!Array.isArray(nodes) || nodes.length === 0) { + throw new Error(`${topologyFile} must contain a non-empty node list`); + } + + for (const node of nodes) { + if (!node.number || !node.key || !node.direct_urls?.length) { + throw new Error(`Invalid node entry in ${topologyFile}`); + } + } + + return nodes; +} + +async function request(fetchImpl, method, url, params) { + const options = { + method, + signal: AbortSignal.timeout(requestTimeoutMs) + }; + + if (method === 'POST') { + options.headers = { 'Content-Type': 'application/json' }; + options.body = JSON.stringify({ requestData64: encode64(Boss.dump(params)) }); + } + + const response = await fetchImpl(url, options); + if (!response.ok) throw new Error(`${method} ${url} returned HTTP ${response.status}`); + + const answer = Boss.load(new Uint8Array(await response.arrayBuffer())); + if (answer?.result !== 'ok') throw answer || new Error(`Invalid response from ${url}`); + return answer.response; +} + +function packedNodeId(node) { + const key = typeof node.key === 'string' ? node.key : encode64(node.key); + return `${node.number}:${key}`; +} + +async function verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds) { + const response = await request(fetchImpl, 'GET', `${node.direct_urls[0]}/topology`); + const packed = response?.packed_data; + const signature = response?.signature; + + if (!packed || !signature || !await publicKey.verifyExtended(signature, packed)) { + throw new Error('invalid topology signature'); + } + + const advertised = Boss.load(packed)?.nodes; + if (!Array.isArray(advertised)) throw new Error('signed topology has no node list'); + + const advertisedNodeIds = new Set(advertised.map(packedNodeId)); + for (const expectedNodeId of expectedNodeIds) { + if (!advertisedNodeIds.has(expectedNodeId)) { + throw new Error('signed topology does not contain every configured node'); + } + } +} + +async function openSession(fetchImpl, node, publicKey, privateKey) { + const baseUrl = node.direct_urls[0]; + const signatureOptions = { pssHash: 'sha512' }; + const clientNonce = randomBytes(47); + const clientKey = await privateKey.publicKey.pack(); + const connection = await request(fetchImpl, 'POST', `${baseUrl}/connect`, { + client_key: clientKey, + client_version: clientVersion + }); + const serverVersion = connection.server_version || 1; + const version = Math.min(serverVersion, clientVersion); + const sessionId = connection.session_id; + const authenticationData = Boss.dump({ + client_nonce: clientNonce, + server_nonce: connection.server_nonce, + client_version: clientVersion, + server_version: serverVersion + }); + const token = await request(fetchImpl, 'POST', `${baseUrl}/get_token`, { + data: authenticationData, + signature: await privateKey.sign(authenticationData, signatureOptions), + session_id: sessionId + }); + + if (!await publicKey.verify(token.data, token.signature, signatureOptions)) { + throw new Error('invalid authentication signature'); + } + + const tokenData = Boss.load(token.data); + if (encode64(tokenData.client_nonce) !== encode64(clientNonce)) { + throw new Error('authentication nonce mismatch'); + } + + const decryptedToken = Boss.load(await privateKey.decrypt(tokenData.encrypted_token)); + const sessionKey = new SymmetricKey({ keyBytes: decryptedToken.sk }); + + return async (name, params = {}) => { + const data = Boss.dump({ command: name, params }); + const encrypted = version >= 2 + ? await sessionKey.etaEncrypt(data) + : await sessionKey.encrypt(data); + const response = await request(fetchImpl, 'POST', `${baseUrl}/command`, { + command: 'command', + params: encrypted, + session_id: sessionId + }); + const decrypted = version >= 2 + ? await sessionKey.etaDecrypt(response.result) + : await sessionKey.decrypt(response.result); + const result = Boss.load(decrypted); + if (result.error) throw result.error; + return result.result; + }; +} + +async function testNode(fetchImpl, node, privateKey, expectedNodeIds) { + const keyBytes = typeof node.key === 'string' ? decode64(node.key) : node.key; + const publicKey = await PublicKey.unpack(keyBytes); + await verifySignedTopology(fetchImpl, node, publicKey, expectedNodeIds); + const command = await openSession(fetchImpl, node, publicKey, privateKey); + const hello = await command('hello'); + if (hello?.status !== 'OK') throw new Error(`hello returned ${hello?.status || 'no status'}`); + const ping = await command('sping'); + if (ping?.sping !== 'spong') throw new Error('sping did not return spong'); +} + +async function main(fetchImpl) { + const nodes = loadTopology(); + const expectedNodeIds = new Set(nodes.map(packedNodeId)); + const quorum = Math.max(1, Math.ceil(nodes.length * 0.4)); + const privateKey = await PrivateKey.generate({ strength: 2048 }); + + console.log(`Testing ${nodes.length} Universa nodes from ${topologyFile}`); + const results = await Promise.all(nodes.map(async (node) => { + try { + await testNode(fetchImpl, node, privateKey, expectedNodeIds); + console.log(`PASS ${node.name}: signed topology, authenticated hello, sping=spong`); + return true; + } catch (error) { + console.error(`FAIL ${node.name}: ${describeError(error)}`); + return false; + } + })); + + const passed = results.filter(Boolean).length; + if (passed < quorum) { + throw new Error(`Universa network quorum failed: ${passed}/${nodes.length} nodes passed; ${quorum} required`); + } + console.log(`Universa network is functioning: ${passed}/${nodes.length} nodes passed (${quorum} required)`); +} + +try { + await withWasmLoader(async (fetchImpl) => { + if (!fetchImpl) throw new Error('This test requires the Node.js fetch API'); + await main(fetchImpl); + }); +} catch (error) { + console.error(describeError(error)); + process.exitCode = 1; +} diff --git a/scripts/test-network.sh b/scripts/test-network.sh new file mode 100755 index 0000000..071ccd5 --- /dev/null +++ b/scripts/test-network.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +exec node scripts/test-network.js diff --git a/src/index.js b/src/index.js index d3217c4..805435d 100644 --- a/src/index.js +++ b/src/index.js @@ -2,14 +2,17 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { createConnection } from 'node:net'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; const require = createRequire(import.meta.url); const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const topologyFile = resolve(scriptDirectory, 'indigo_demo_2025.json'); +const topologyFile = resolve(scriptDirectory, 'universa.json'); const universaCoreDist = resolve(scriptDirectory, '../node_modules/universa-core/dist'); +const networkTimeoutMs = 20000; +const endpointProbeTimeoutMs = 3000; function withTimeout(promise, timeoutMs, message) { let timeoutId; @@ -50,14 +53,42 @@ async function withUniversaWasmLoader(callback) { } function loadUniversaCore() { - const window = globalThis.window; - globalThis.window = globalThis; + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const localStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: globalThis + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + hardwareConcurrency: 1, + languages: ['en-US'] + } + }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} + } + }); try { return require('universa-core'); } finally { - if (window === undefined) delete globalThis.window; - else globalThis.window = window; + if (windowDescriptor) Object.defineProperty(globalThis, 'window', windowDescriptor); + else delete globalThis.window; + + if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + else delete globalThis.navigator; + + if (localStorageDescriptor) Object.defineProperty(globalThis, 'localStorage', localStorageDescriptor); + else delete globalThis.localStorage; } } @@ -70,18 +101,72 @@ function generatePrivateKey() { async function loadTopology(Topology) { return withUniversaWasmLoader(async () => { - const packedTopology = JSON.parse(readFileSync(topologyFile, 'utf8')); + const packedTopology = readTopology(); return Topology.load(packedTopology); }); } +function readTopology() { + const parsedTopology = JSON.parse(readFileSync(topologyFile, 'utf8')); + const packedTopology = Array.isArray(parsedTopology) + ? { list: parsedTopology, updated: Math.floor(Date.now() / 1000) } + : parsedTopology; + + if (!Array.isArray(packedTopology.list)) { + throw new Error(`Topology file must contain a node array or an object with a list array: ${topologyFile}`); + } + + return packedTopology; +} + +async function probeEndpoint(url) { + return new Promise((resolveProbe) => { + const endpoint = new URL(url); + const port = endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80); + const socket = createConnection({ + host: endpoint.hostname, + port: Number(port), + timeout: endpointProbeTimeoutMs + }); + + function finish(ok, error = '') { + socket.destroy(); + resolveProbe({ url, ok, error }); + } + + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false, 'TCP connection timed out')); + socket.once('error', (error) => finish(false, error.message)); + }); +} + +async function assertReachableTopology(directConnection) { + const topology = readTopology(); + const urls = topology.list.flatMap((node) => ( + directConnection ? node.direct_urls : node.domain_urls + ) || []); + + if (urls.length === 0) { + throw new Error(`Topology file does not contain ${directConnection ? 'direct_urls' : 'domain_urls'} entries: ${topologyFile}`); + } + + const results = await Promise.all(urls.map(probeEndpoint)); + if (results.some((result) => result.ok)) return; + + const details = results + .map(({ url, error }) => ` ${url}/topology: ${error}`) + .join('\n'); + + throw new Error(`No reachable topology endpoints in ${topologyFile}:\n${details}`); +} + const program = new Command(); program .name('u-js-sample') .description('Example Node.js console script with universa-core functions.') .option('--generate-key', 'generate a Universa private key and print its short address') - .option('--test-network', 'generate a key and connect to the Universa network from indigo_demo_2025.json') + .option('--test-network', `generate a key and connect to the Universa network from ${basename(topologyFile)}`) .action(async (options) => { if (options.generateKey || options.testNetwork) { const privateKey = await generatePrivateKey(); @@ -90,10 +175,12 @@ program if (options.testNetwork) { const { Network, Topology } = loadUniversaCore(); const topology = await loadTopology(Topology); - const network = new Network(privateKey, { topology, directConnection: true }); + const directConnection = true; + const network = new Network(privateKey, { topology, directConnection }); console.log(`Connecting with topology: ${topologyFile}`); - await withTimeout(network.connect(), 20000, 'Network connection timed out'); + await assertReachableTopology(directConnection); + await withTimeout(network.connect(), networkTimeoutMs, 'Network connection timed out'); console.log(`Connected to ${network.size()} nodes`); const response = await withTimeout(network.command('sping'), 10000, 'Network sping command timed out'); diff --git a/src/indigo_demo_2025.json b/src/indigo_demo_2025.json deleted file mode 100644 index d2e07b9..0000000 --- a/src/indigo_demo_2025.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "list": [ - { - "number": 1, - "domain_urls": [ - "https://31.41.155.11" - ], - "name": "node-01", - "direct_urls": [ - "http://31.41.155.11:8080" - ], - "key": "HggcAQABxAAB5IfycVepakIjOt3c5eyLFswYH8dokrmHTyRIgaCSz+EY+mm8hM2Cn1PJjyiJj4K1A8CPN9p9mj6z/yyGSH6pvW3HMGs6Az7KvhGbZJlsFP0tNy6A3YQEWP/XToSqtT3f0Ji/r2nYw2zEgnGvOBkkcyN/EEslnxTj1R3fZlaSHh9Uem6NFk62ZxSBj8hJkvfHbTB/XZ2z8wtpwcAJClylITbbCSg9RdXoD8ZwCJMaCWasxfdo5+sMYTcABYFErbw8ndeQ5aL3N2+XiFM8WO5wQnd3SNTYUQGucBkXO5zqmZjnreVOWIsQtKzjwH4WIi57kz+87V72TbDrX0jYMYsOSw==" - }, - { - "number": 2, - "domain_urls": [ - "https://37.9.5.102" - ], - "name": "node-02", - "direct_urls": [ - "http://37.9.5.102:8080" - ], - "key": "HggcAQABxAAB3GST2bRm2SrwypfKZRplqDwLUvDF7DoRgVMZfzOHzYodHfIpNrRKZfJdr63UdpK6359G1TAAHm52UaLCaCqBZNuFDqE9s9fEe+2UoSzoMKGG0NtgFD2HdDhuNWw4LnqZiZ5lPlZuUKtxVCzhlI0SNweBKMn0oopjdOYfx7hkPHVB2TyADXPrcnxFHEpc6USjJgP3aHPgum4CxYD09KTF59t5W+B+rDpTd/ro3ENBSFi9xlt4qMrLx5KzIdm8IqcsEm+mTYS5mx0F4e6wlCiXElwGe1NdXNwb/A8S2JBSUKP2CfSKdJtB0D7GHFnMuF3+u29/pykBOvlUoUa4S5QxJQ==" - }, - { - "number": 3, - "domain_urls": [ - "https://37.9.4.133" - ], - "name": "node-03", - "direct_urls": [ - "http://37.9.4.133:8080" - ], - "key": "HggcAQABxAABtTajk8wkzstB+mmj09fkP/a9IqX4tVuuXnd/fO6a3TAjNbulqtkvVyfhu2DeUdUFiYP9kc+YQmHsIe5T/xZgdecWAbHKT0Ok530nLju8dog+75j6aCqD+winCCb+s7dzAYNIsKS0QgBPPWyBUyDL+di17s2Z5U2mibV+sXQYfWU4Gy02WNuavC8QZ0gA7K3Ah6ohR/Yn37WpiWvS+kQOnZF/q/8LYRQgxdDrztzTgpouggwk/1VJ181fYcXq/54n+L5yV2M0GZy7B942ce07MV6EGmQJu4eoOQi8DN3Ee30WOuIIiEyiQLMJjva0uxvkXmlftsr8rGiLXB+kiMd2fQ==" - }, - { - "number": 4, - "domain_urls": [ - "https://37.9.4.137" - ], - "name": "node-04", - "direct_urls": [ - "http://37.9.4.137:8080" - ], - "key": "HggcAQABxAABkGzNgQnnNh/aNR3zQujD/6mgcqqfZ3tl14DDLhXLunUhc8hkn3y+ViukIj1qbkPi6Ps5irDZ2TzVfepoVWqX+XkI8wUbXKpDN/zWYx9kW4/soA4C7KS6fS/NFsFflUyzDNQeQXmiQkPTNnn43sdSqYb6Zr3I7Wa6mEwIP7MakKo6SbJQ/gTdXPlKWnU1nGJISMowCfhawYQLZgI5URsgS3VV7Owb3Yo4IZ0YNDTceboY4ljYuc/R2LqJcGjcWfSweaHV2T64RY6yQt1W0h2KweK99cflJdh/QSCqBhRdSYYfgKDlmp2ZTvrrfj0bbz2mPBh5JG5RQe+O9MzT0Is4ew==" - } - ], - "updated": 1742911675 -} \ No newline at end of file diff --git a/src/universa.json b/src/universa.json new file mode 100644 index 0000000..48540fc --- /dev/null +++ b/src/universa.json @@ -0,0 +1,46 @@ +[ + { + "number": 1, + "domain_urls": [ + "https://38.60.255.83" + ], + "name": "node-01", + "direct_urls": [ + "http://38.60.255.83:8080" + ], + "key": "HggcAQABxAAB5IfycVepakIjOt3c5eyLFswYH8dokrmHTyRIgaCSz+EY+mm8hM2Cn1PJjyiJj4K1A8CPN9p9mj6z/yyGSH6pvW3HMGs6Az7KvhGbZJlsFP0tNy6A3YQEWP/XToSqtT3f0Ji/r2nYw2zEgnGvOBkkcyN/EEslnxTj1R3fZlaSHh9Uem6NFk62ZxSBj8hJkvfHbTB/XZ2z8wtpwcAJClylITbbCSg9RdXoD8ZwCJMaCWasxfdo5+sMYTcABYFErbw8ndeQ5aL3N2+XiFM8WO5wQnd3SNTYUQGucBkXO5zqmZjnreVOWIsQtKzjwH4WIi57kz+87V72TbDrX0jYMYsOSw==" + }, + { + "number": 2, + "domain_urls": [ + "https://38.60.238.117" + ], + "name": "node-02", + "direct_urls": [ + "http://38.60.238.117:8080" + ], + "key": "HggcAQABxAAB3GST2bRm2SrwypfKZRplqDwLUvDF7DoRgVMZfzOHzYodHfIpNrRKZfJdr63UdpK6359G1TAAHm52UaLCaCqBZNuFDqE9s9fEe+2UoSzoMKGG0NtgFD2HdDhuNWw4LnqZiZ5lPlZuUKtxVCzhlI0SNweBKMn0oopjdOYfx7hkPHVB2TyADXPrcnxFHEpc6USjJgP3aHPgum4CxYD09KTF59t5W+B+rDpTd/ro3ENBSFi9xlt4qMrLx5KzIdm8IqcsEm+mTYS5mx0F4e6wlCiXElwGe1NdXNwb/A8S2JBSUKP2CfSKdJtB0D7GHFnMuF3+u29/pykBOvlUoUa4S5QxJQ==" + }, + { + "number": 3, + "domain_urls": [ + "https://130.94.0.186" + ], + "name": "node-03", + "direct_urls": [ + "http://130.94.0.186:8080" + ], + "key": "HggcAQABxAABtTajk8wkzstB+mmj09fkP/a9IqX4tVuuXnd/fO6a3TAjNbulqtkvVyfhu2DeUdUFiYP9kc+YQmHsIe5T/xZgdecWAbHKT0Ok530nLju8dog+75j6aCqD+winCCb+s7dzAYNIsKS0QgBPPWyBUyDL+di17s2Z5U2mibV+sXQYfWU4Gy02WNuavC8QZ0gA7K3Ah6ohR/Yn37WpiWvS+kQOnZF/q/8LYRQgxdDrztzTgpouggwk/1VJ181fYcXq/54n+L5yV2M0GZy7B942ce07MV6EGmQJu4eoOQi8DN3Ee30WOuIIiEyiQLMJjva0uxvkXmlftsr8rGiLXB+kiMd2fQ==" + }, + { + "number": 4, + "domain_urls": [ + "https://38.60.208.39" + ], + "name": "node-04", + "direct_urls": [ + "http://38.60.208.39:8080" + ], + "key": "HggcAQABxAABkGzNgQnnNh/aNR3zQujD/6mgcqqfZ3tl14DDLhXLunUhc8hkn3y+ViukIj1qbkPi6Ps5irDZ2TzVfepoVWqX+XkI8wUbXKpDN/zWYx9kW4/soA4C7KS6fS/NFsFflUyzDNQeQXmiQkPTNnn43sdSqYb6Zr3I7Wa6mEwIP7MakKo6SbJQ/gTdXPlKWnU1nGJISMowCfhawYQLZgI5URsgS3VV7Owb3Yo4IZ0YNDTceboY4ljYuc/R2LqJcGjcWfSweaHV2T64RY6yQt1W0h2KweK99cflJdh/QSCqBhRdSYYfgKDlmp2ZTvrrfj0bbz2mPBh5JG5RQe+O9MzT0Is4ew==" + } +] \ No newline at end of file