diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index aa0a7dbf2..b583156b5 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -43,6 +43,7 @@ const DLT_CONNECTOR_PORT = process.env.DLT_CONNECTOR_PORT ?? 6010 const dltConnector = { DLT_CONNECTOR: process.env.DLT_CONNECTOR === 'true' || false, DLT_CONNECTOR_URL: process.env.DLT_CONNECTOR_URL ?? `${COMMUNITY_URL}:${DLT_CONNECTOR_PORT}`, + DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: process.env.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER ?? '~/.gradido', } const community = { diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index 144608992..f4e6033ea 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -79,6 +79,10 @@ export const schema = Joi.object({ .when('DLT_CONNECTOR', { is: true, then: Joi.required() }) .description('The URL for GDT API endpoint'), + DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: Joi.string() + .default('~/.gradido') + .description('The home folder for the gradido dlt node server'), + EMAIL: Joi.boolean() .default(false) .description('Enable or disable email functionality') diff --git a/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts b/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts index 1abbeb9e7..7c228c799 100644 --- a/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts +++ b/backend/src/federation/client/1_0/model/PublicCommunityInfo.ts @@ -4,4 +4,5 @@ export interface PublicCommunityInfo { creationDate: Date publicKey: string publicJwtKey: string + hieroTopicId: string | null } diff --git a/backend/src/federation/validateCommunities.ts b/backend/src/federation/validateCommunities.ts index 10088cf35..3b910b313 100644 --- a/backend/src/federation/validateCommunities.ts +++ b/backend/src/federation/validateCommunities.ts @@ -2,6 +2,7 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity, getHomeCommunity, + getReachableCommunities, } from 'database' import { IsNull } from 'typeorm' @@ -15,6 +16,9 @@ import { getLogger } from 'log4js' import { startCommunityAuthentication } from './authenticateCommunities' import { PublicCommunityInfoLoggingView } from './client/1_0/logging/PublicCommunityInfoLogging.view' import { ApiVersionType } from 'core' +import { CONFIG } from '@/config' +import * as path from 'node:path' +import * as fs from 'node:fs' const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.validateCommunities`) @@ -83,6 +87,8 @@ export async function validateCommunities(): Promise { logger.error(`Error:`, err) } } + // export communities for gradido dlt node server + await exportCommunitiesToDltNodeServer() } export async function writeJwtKeyPairInHomeCommunity(): Promise { @@ -138,6 +144,52 @@ async function writeForeignCommunity( com.publicKey = dbCom.publicKey com.publicJwtKey = pubInfo.publicJwtKey com.url = dbCom.endPoint + com.hieroTopicId = pubInfo.hieroTopicId await DbCommunity.save(com) } } + +// prototype, later add api call to gradido dlt node server for adding/updating communities +type CommunityForDltNodeServer = { + communityId: string + hieroTopicId: string + alias: string + folder: string +} +async function exportCommunitiesToDltNodeServer(): Promise { + if (!CONFIG.DLT_CONNECTOR) { + return Promise.resolve() + } + const folder = CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER + try { + fs.accessSync(folder, fs.constants.R_OK | fs.constants.W_OK) + } catch (err) { + logger.error(`Error: home folder for DLT Gradido Node Server ${folder} does not exist`) + return + } + + const dbComs = await getReachableCommunities(CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER * 4) + const communitiesForDltNodeServer: CommunityForDltNodeServer[] = [] + // make sure communityName is unique + const communityName = new Set() + dbComs.forEach((com) => { + if (!com.communityUuid || !com.hieroTopicId) { + return + } + let alias = com.name + if (!alias || communityName.has(alias)) { + alias = com.communityUuid + } + communityName.add(alias) + communitiesForDltNodeServer.push({ + communityId: com.communityUuid, + hieroTopicId: com.hieroTopicId, + alias, + // use only alpha-numeric chars for folder name + folder: alias.replace(/[^a-zA-Z0-9]/g, '_') + }) + }) + const dltNodeServerCommunitiesFile = path.join(folder, 'communities.json') + fs.writeFileSync(dltNodeServerCommunitiesFile, JSON.stringify(communitiesForDltNodeServer, null, 2)) + logger.debug(`Written communitiesForDltNodeServer to ${dltNodeServerCommunitiesFile}`) +} diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index 76d6a76f5..abe2d0202 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -90,6 +90,7 @@ DLT_CONNECTOR=false DLT_CONNECTOR_PORT=6010 DLT_NODE_SERVER_PORT=8340 DLT_NODE_SERVER_URL=$URL_PROTOCOL://$COMMUNITY_HOST/dlt +DLT_GRADIDO_NODE_SERVER_HOME_FOLDER=/home/gradido/.gradido # used for combining a newsletter on klicktipp with this gradido community # if used, user will be subscribed on register and can unsubscribe in his account diff --git a/dlt-connector/.env.dist b/dlt-connector/.env.dist index ff40bd5d1..8253699ca 100644 --- a/dlt-connector/.env.dist +++ b/dlt-connector/.env.dist @@ -13,12 +13,12 @@ IOTA_HOME_COMMUNITY_SEED=aabbccddeeff00112233445566778899aabbccddeeff00112233445 DLT_CONNECTOR_PORT=6010 # Gradido Node Server URL -NODE_SERVER_URL=http://localhost:8340/api +DLT_NODE_SERVER_PORT=8340 # Gradido Blockchain GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET=21ffbbc616fe GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY=a51ef8ac7ef1abf162fb7a65261acd7a # Route to Backend -BACKEND_SERVER_URL=http://localhost:4000 +PORT=4000 JWT_SECRET=secret123 \ No newline at end of file diff --git a/dlt-connector/.gitignore b/dlt-connector/.gitignore index 5435dd5ff..4c6422640 100644 --- a/dlt-connector/.gitignore +++ b/dlt-connector/.gitignore @@ -7,3 +7,4 @@ package-json.lock coverage # emacs *~ +gradido_node \ No newline at end of file diff --git a/dlt-connector/bun.lock b/dlt-connector/bun.lock index 7ea3a4eab..46e566b54 100644 --- a/dlt-connector/bun.lock +++ b/dlt-connector/bun.lock @@ -11,8 +11,11 @@ "@hashgraph/sdk": "^2.70.0", "@sinclair/typebox": "^0.34.33", "@sinclair/typemap": "^0.10.1", + "@types/adm-zip": "^0.5.7", "@types/bun": "^1.2.17", "@types/uuid": "^8.3.4", + "adm-zip": "^0.5.16", + "async-mutex": "^0.5.0", "dotenv": "^10.0.0", "elysia": "1.3.8", "graphql-request": "^7.2.0", @@ -248,6 +251,8 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -284,6 +289,8 @@ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], @@ -306,6 +313,8 @@ "async-limiter": ["async-limiter@1.0.1", "", {}, "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], diff --git a/dlt-connector/package.json b/dlt-connector/package.json index 7dc4e590d..e1bef07c9 100644 --- a/dlt-connector/package.json +++ b/dlt-connector/package.json @@ -24,8 +24,11 @@ "@hashgraph/sdk": "^2.70.0", "@sinclair/typebox": "^0.34.33", "@sinclair/typemap": "^0.10.1", + "@types/adm-zip": "^0.5.7", "@types/bun": "^1.2.17", "@types/uuid": "^8.3.4", + "adm-zip": "^0.5.16", + "async-mutex": "^0.5.0", "dotenv": "^10.0.0", "elysia": "1.3.8", "graphql-request": "^7.2.0", diff --git a/dlt-connector/src/bootstrap/init.ts b/dlt-connector/src/bootstrap/init.ts index 1d70f4003..13b77783c 100644 --- a/dlt-connector/src/bootstrap/init.ts +++ b/dlt-connector/src/bootstrap/init.ts @@ -8,6 +8,7 @@ import { SendToHieroContext } from '../interactions/sendToHiero/SendToHiero.cont import { Community, communitySchema } from '../schemas/transaction.schema' import { isPortOpenRetry } from '../utils/network' import { type AppContext, type AppContextClients } from './appContext' +import { initGradidoNode } from './initGradidoNode' export function loadConfig(): Logger { // configure log4js @@ -36,7 +37,7 @@ export async function checkHomeCommunity( const { backend, hiero } = appContext.clients // wait for backend server - await isPortOpenRetry(CONFIG.BACKEND_SERVER_URL) + await isPortOpenRetry(backend.url) // ask backend for home community let homeCommunity = await backend.getHomeCommunityDraft() // on missing topicId, create one @@ -64,8 +65,8 @@ export async function checkHomeCommunity( } appContext.cache.setHomeCommunityTopicId(homeCommunity.hieroTopicId) logger.info(`home community topic: ${homeCommunity.hieroTopicId}`) - logger.info(`gradido node server: ${CONFIG.NODE_SERVER_URL}`) - logger.info(`gradido backend server: ${CONFIG.BACKEND_SERVER_URL}`) + logger.info(`gradido node server: ${appContext.clients.gradidoNode.url}`) + logger.info(`gradido backend server: ${appContext.clients.backend.url}`) return v.parse(communitySchema, homeCommunity) } @@ -74,6 +75,9 @@ export async function checkGradidoNode( logger: Logger, homeCommunity: Community, ): Promise { + // check if gradido node is running, if not setup and start it + await initGradidoNode(clients) + // ask gradido node if community blockchain was created try { if ( diff --git a/dlt-connector/src/bootstrap/initGradidoNode.ts b/dlt-connector/src/bootstrap/initGradidoNode.ts new file mode 100644 index 000000000..7c652a667 --- /dev/null +++ b/dlt-connector/src/bootstrap/initGradidoNode.ts @@ -0,0 +1,89 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import AdmZip from 'adm-zip' +import { getLogger } from 'log4js' +import { exportCommunities } from '../client/GradidoNode/communities' +import { GradidoNodeProcess } from '../client/GradidoNode/GradidoNodeProcess' +import { HieroClient } from '../client/hiero/HieroClient' +import { CONFIG } from '../config' +import { + GRADIDO_NODE_HOME_FOLDER_NAME, + GRADIDO_NODE_RUNTIME_PATH, + LOG4JS_BASE_CATEGORY, +} from '../config/const' +import { checkFileExist, checkPathExist } from '../utils/filesystem' +import { isPortOpen } from '../utils/network' +import { AppContextClients } from './appContext' + +const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.bootstrap.initGradidoNode`) + +export async function initGradidoNode(clients: AppContextClients): Promise { + const url = `http://localhost:${CONFIG.DLT_NODE_SERVER_PORT}` + const isOpen = await isPortOpen(url) + if (isOpen) { + logger.info(`GradidoNode is already running on ${url}`) + return + } + + const gradidoNodeHomeFolder = path.join( + CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER, + GRADIDO_NODE_HOME_FOLDER_NAME, + ) + // check folder, create when missing + checkPathExist(gradidoNodeHomeFolder, true) + + await Promise.all([ + // write Hedera Address Book + exportHederaAddressbooks(gradidoNodeHomeFolder, clients.hiero), + // check GradidoNode Runtime, download when missing + ensureGradidoNodeRuntimeAvailable(GRADIDO_NODE_RUNTIME_PATH), + // export communities to GradidoNode Folder + exportCommunities(gradidoNodeHomeFolder, clients.backend), + ]) + GradidoNodeProcess.getInstance().start() +} + +async function exportHederaAddressbooks( + homeFolder: string, + hieroClient: HieroClient, +): Promise { + const networkName = CONFIG.HIERO_HEDERA_NETWORK + const addressBook = await hieroClient.downloadAddressBook() + const addressBookPath = path.join(homeFolder, 'addressbook', `${networkName}.pb`) + checkPathExist(path.dirname(addressBookPath), true) + fs.writeFileSync(addressBookPath, addressBook.toBytes()) +} + +async function ensureGradidoNodeRuntimeAvailable(runtimeFileName: string): Promise { + const runtimeFolder = path.dirname(runtimeFileName) + checkPathExist(runtimeFolder, true) + if (!checkFileExist(runtimeFileName)) { + const runtimeArchiveFilename = createGradidoNodeRuntimeArchiveFilename() + const downloadUrl = new URL( + `https://github.com/gradido/gradido_node/releases/download/v${CONFIG.DLT_GRADIDO_NODE_SERVER_VERSION}/${runtimeArchiveFilename}`, + ) + logger.debug(`download GradidoNode Runtime from ${downloadUrl}`) + const archive = await fetch(downloadUrl) + if (!archive.ok) { + throw new Error(`Failed to download GradidoNode Runtime: ${archive.statusText}`) + } + const compressedBuffer = await archive.arrayBuffer() + if (process.platform === 'win32') { + const zip = new AdmZip(Buffer.from(compressedBuffer)) + zip.extractAllTo(runtimeFolder, true) + } else { + const archivePath = path.join(runtimeFolder, runtimeArchiveFilename) + logger.debug(`GradidoNode Runtime Archive: ${archivePath}`) + fs.writeFileSync(archivePath, Buffer.from(compressedBuffer)) + execSync(`tar -xzf ${archivePath}`, { cwd: runtimeFolder }) + } + } +} + +function createGradidoNodeRuntimeArchiveFilename(): string { + const version = CONFIG.DLT_GRADIDO_NODE_SERVER_VERSION + const platform: string = process.platform + const fileEnding = platform === 'win32' ? 'zip' : 'tar.gz' + return `gradido_node-v${version}-${platform}-${process.arch}.${fileEnding}` +} diff --git a/dlt-connector/src/bootstrap/shutdown.ts b/dlt-connector/src/bootstrap/shutdown.ts index 781430008..415742d38 100644 --- a/dlt-connector/src/bootstrap/shutdown.ts +++ b/dlt-connector/src/bootstrap/shutdown.ts @@ -1,4 +1,5 @@ import { Logger } from 'log4js' +import { GradidoNodeProcess } from '../client/GradidoNode/GradidoNodeProcess' import { type AppContextClients } from './appContext' export function setupGracefulShutdown(logger: Logger, clients: AppContextClients) { @@ -25,4 +26,5 @@ export function setupGracefulShutdown(logger: Logger, clients: AppContextClients async function gracefulShutdown(logger: Logger, clients: AppContextClients) { logger.info('graceful shutdown') await clients.hiero.waitForPendingPromises() + await GradidoNodeProcess.getInstance().exit() } diff --git a/dlt-connector/src/client/GradidoNode/GradidoNodeClient.ts b/dlt-connector/src/client/GradidoNode/GradidoNodeClient.ts index efbfc6d22..d66a58887 100644 --- a/dlt-connector/src/client/GradidoNode/GradidoNodeClient.ts +++ b/dlt-connector/src/client/GradidoNode/GradidoNodeClient.ts @@ -36,13 +36,21 @@ export class GradidoNodeClient { private static instance: GradidoNodeClient client: JsonRpcClient logger: Logger + urlValue: string private constructor() { this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNodeClient`) + this.urlValue = `http://localhost:${CONFIG.DLT_NODE_SERVER_PORT}/api` + this.logger.addContext('url', this.urlValue) this.client = new JsonRpcClient({ - url: CONFIG.NODE_SERVER_URL, + url: this.urlValue, }) } + + public get url(): string { + return this.urlValue + } + public static getInstance(): GradidoNodeClient { if (!GradidoNodeClient.instance) { GradidoNodeClient.instance = new GradidoNodeClient() @@ -233,7 +241,7 @@ export class GradidoNodeClient { // template rpcCall, check first if port is open before executing json rpc 2.0 request protected async rpcCall(method: string, parameter: any): Promise> { this.logger.debug('call %s with %s', method, parameter) - await isPortOpenRetry(CONFIG.NODE_SERVER_URL) + await isPortOpenRetry(this.url) return this.client.exec(method, parameter) } diff --git a/dlt-connector/src/client/GradidoNode/GradidoNodeProcess.ts b/dlt-connector/src/client/GradidoNode/GradidoNodeProcess.ts new file mode 100644 index 000000000..eb2bf6b66 --- /dev/null +++ b/dlt-connector/src/client/GradidoNode/GradidoNodeProcess.ts @@ -0,0 +1,121 @@ +import { Subprocess, spawn } from 'bun' +import { getLogger, Logger } from 'log4js' +import { CONFIG } from '../../config' +import { + GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS, + GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS, + GRADIDO_NODE_RUNTIME_PATH, + LOG4JS_BASE_CATEGORY, +} from '../../config/const' + +/** + * A Singleton class defines the `getInstance` method that lets clients access + * the unique singleton instance. + * + * Singleton Managing GradidoNode if started as subprocess + * will restart GradidoNode if it exits more than `GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS` milliseconds after start + * if exit was called, it will first try to exit graceful with SIGTERM and then kill with SIGKILL after `GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS` milliseconds + */ +export class GradidoNodeProcess { + private static instance: GradidoNodeProcess | null = null + private proc: Subprocess | null = null + private logger: Logger + private lastStarted: Date | null = null + private exitCalled: boolean = false + + private constructor() { + // constructor is private to prevent instantiation from outside + // of the class except from the static getInstance method. + this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNodeProcess`) + } + + /** + * Static method that returns the singleton instance of the class. + * @returns the singleton instance of the class. + */ + public static getInstance(): GradidoNodeProcess { + if (!GradidoNodeProcess.instance) { + GradidoNodeProcess.instance = new GradidoNodeProcess() + } + return GradidoNodeProcess.instance + } + + public start() { + if (this.proc) { + this.logger.warn('GradidoNodeProcess already running.') + return + } + this.logger.info(`starting GradidoNodeProcess with path: ${GRADIDO_NODE_RUNTIME_PATH}`) + this.lastStarted = new Date() + const logger = this.logger + this.proc = spawn([GRADIDO_NODE_RUNTIME_PATH], { + env: { + CLIENTS_HIERO_NETWORKTYPE: CONFIG.HIERO_HEDERA_NETWORK, + SERVER_JSON_RPC_PORT: CONFIG.DLT_NODE_SERVER_PORT.toString(), + USERPROFILE: CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER, + HOME: CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER, + }, + onExit(proc, exitCode, signalCode, error) { + logger.warn(`GradidoNodeProcess exited with code ${exitCode} and signalCode ${signalCode}`) + if (error) { + logger.error(`GradidoNodeProcess exit error: ${error}`) + /*if (logger.isDebugEnabled() && proc.stderr) { + // print error messages from GradidoNode in our own log if debug is enabled + proc.stderr + .getReader() + .read() + .then((chunk) => { + logger.debug(chunk.value?.toString()) + }) + }*/ + } + logger.debug(`ressource usage: ${JSON.stringify(proc?.resourceUsage(), null, 2)}`) + const gradidoNodeProcess = GradidoNodeProcess.getInstance() + gradidoNodeProcess.proc = null + if ( + !gradidoNodeProcess.exitCalled && + gradidoNodeProcess.lastStarted && + Date.now() - gradidoNodeProcess.lastStarted.getTime() > + GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS + ) { + // restart only if enough time was passed since last start to prevent restart loop + gradidoNodeProcess.start() + } + }, + /*stdout: 'ignore', + stderr: logger.isDebugEnabled() ? 'pipe' : 'ignore',*/ + stdout: 'inherit', + stderr: 'inherit', + }) + } + + public async restart() { + if (this.proc) { + await this.exit() + this.exitCalled = false + this.start() + } + } + + public async exit(): Promise { + this.exitCalled = true + if (this.proc) { + this.proc.kill('SIGTERM') + const timeout = setTimeout(() => { + this.logger.warn( + `GradidoNode couldn't exit graceful after ${GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS} milliseconds with SIGTERM, killing with SIGKILL`, + ) + this.proc?.kill('SIGKILL') + }, GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS) + try { + await this.proc.exited + } catch (error) { + this.logger.error(`GradidoNodeProcess exit error: ${error}`) + } finally { + clearTimeout(timeout) + } + } else { + return Promise.resolve() + } + } +} diff --git a/dlt-connector/src/client/GradidoNode/communities.ts b/dlt-connector/src/client/GradidoNode/communities.ts new file mode 100644 index 000000000..d25ed4dd4 --- /dev/null +++ b/dlt-connector/src/client/GradidoNode/communities.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Mutex } from 'async-mutex' +import { getLogger } from 'log4js' +import { CONFIG } from '../../config' +import { GRADIDO_NODE_HOME_FOLDER_NAME, LOG4JS_BASE_CATEGORY } from '../../config/const' +import { HieroId } from '../../schemas/typeGuard.schema' +import { checkFileExist, checkPathExist } from '../../utils/filesystem' +import { BackendClient } from '../backend/BackendClient' +import { GradidoNodeProcess } from './GradidoNodeProcess' + +const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNode.communities`) +const ensureCommunitiesAvailableMutex: Mutex = new Mutex() + +// prototype, later add api call to gradido dlt node server for adding/updating communities +type CommunityForDltNodeServer = { + communityId: string + hieroTopicId: string + alias: string + folder: string +} + +export async function ensureCommunitiesAvailable(communityTopicIds: HieroId[]): Promise { + const release = await ensureCommunitiesAvailableMutex.acquire() + try { + const homeFolder = path.join( + CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER, + GRADIDO_NODE_HOME_FOLDER_NAME, + ) + if (!checkCommunityAvailable(communityTopicIds, homeFolder)) { + await exportCommunities(homeFolder, BackendClient.getInstance()) + return GradidoNodeProcess.getInstance().restart() + } + } finally { + release() + } +} + +export async function exportCommunities(homeFolder: string, client: BackendClient): Promise { + const communities = await client.getReachableCommunities() + const communitiesPath = path.join(homeFolder, 'communities.json') + checkPathExist(path.dirname(communitiesPath), true) + // make sure communityName is unique + const communityName = new Set() + const communitiesForDltNodeServer: CommunityForDltNodeServer[] = [] + for (const com of communities) { + if (!com.uuid || !com.hieroTopicId) { + continue + } + // use name as alias if not empty and unique, otherwise use uuid + let alias = com.name + if (!alias || communityName.has(alias)) { + alias = com.uuid + } + communityName.add(alias) + communitiesForDltNodeServer.push({ + communityId: com.uuid, + hieroTopicId: com.hieroTopicId, + alias, + // use only alpha-numeric chars for folder name + folder: alias.replace(/[^a-zA-Z0-9]/g, '_'), + }) + } + fs.writeFileSync(communitiesPath, JSON.stringify(communitiesForDltNodeServer, null, 2)) + logger.info(`exported ${communitiesForDltNodeServer.length} communities to ${communitiesPath}`) +} + +export function checkCommunityAvailable(communityTopicIds: HieroId[], homeFolder: string): boolean { + const communitiesPath = path.join(homeFolder, 'communities.json') + if (!checkFileExist(communitiesPath)) { + return false + } + const communities = JSON.parse(fs.readFileSync(communitiesPath, 'utf-8')) + let foundCount = 0 + for (const community of communities) { + if (communityTopicIds.includes(community.hieroTopicId)) { + foundCount++ + if (foundCount >= communityTopicIds.length) { + return true + } + } + } + return false +} diff --git a/dlt-connector/src/client/backend/BackendClient.ts b/dlt-connector/src/client/backend/BackendClient.ts index d5cc3be9e..7f779fd73 100644 --- a/dlt-connector/src/client/backend/BackendClient.ts +++ b/dlt-connector/src/client/backend/BackendClient.ts @@ -5,7 +5,11 @@ import * as v from 'valibot' import { CONFIG } from '../../config' import { LOG4JS_BASE_CATEGORY } from '../../config/const' import { HieroId, Uuidv4 } from '../../schemas/typeGuard.schema' -import { homeCommunityGraphqlQuery, setHomeCommunityTopicId } from './graphql' +import { + getReachableCommunities, + homeCommunityGraphqlQuery, + setHomeCommunityTopicId, +} from './graphql' import { type Community, communitySchema } from './output.schema' // Source: https://refactoring.guru/design-patterns/singleton/typescript/example @@ -18,6 +22,7 @@ export class BackendClient { private static instance: BackendClient client: GraphQLClient logger: Logger + urlValue: string /** * The Singleton's constructor should always be private to prevent direct @@ -25,8 +30,10 @@ export class BackendClient { */ private constructor() { this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.BackendClient`) - this.logger.addContext('url', CONFIG.BACKEND_SERVER_URL) - this.client = new GraphQLClient(CONFIG.BACKEND_SERVER_URL, { + this.urlValue = `http://localhost:${CONFIG.PORT}` + this.logger.addContext('url', this.urlValue) + + this.client = new GraphQLClient(this.urlValue, { headers: { 'content-type': 'application/json', }, @@ -38,6 +45,10 @@ export class BackendClient { }) } + public get url(): string { + return this.urlValue + } + /** * The static method that controls the access to the singleton instance. * @@ -77,6 +88,19 @@ export class BackendClient { return v.parse(communitySchema, data.updateHomeCommunity) } + public async getReachableCommunities(): Promise { + this.logger.info('get reachable communities on backend') + const { data, errors } = await this.client.rawRequest<{ reachableCommunities: Community[] }>( + getReachableCommunities, + {}, + await this.getRequestHeader(), + ) + if (errors) { + throw errors[0] + } + return v.parse(v.array(communitySchema), data.reachableCommunities) + } + private async getRequestHeader(): Promise<{ authorization: string }> { diff --git a/dlt-connector/src/client/backend/graphql.ts b/dlt-connector/src/client/backend/graphql.ts index 11d1eb099..fafd77fcd 100644 --- a/dlt-connector/src/client/backend/graphql.ts +++ b/dlt-connector/src/client/backend/graphql.ts @@ -4,17 +4,24 @@ import { gql } from 'graphql-request' * Schema Definitions for graphql requests */ +const communityFragment = gql` + fragment Community_common on Community { + uuid + name + hieroTopicId + foreign + creationDate + } +` + // graphql query for getting home community in tune with community schema export const homeCommunityGraphqlQuery = gql` query { homeCommunity { - uuid - name - hieroTopicId - foreign - creationDate + ...Community_common } } + ${communityFragment} ` export const setHomeCommunityTopicId = gql` @@ -28,3 +35,12 @@ export const setHomeCommunityTopicId = gql` } } ` + +export const getReachableCommunities = gql` + query { + reachableCommunities { + ...Community_common + } + } + ${communityFragment} +` diff --git a/dlt-connector/src/client/hiero/HieroClient.ts b/dlt-connector/src/client/hiero/HieroClient.ts index e48eefc92..33a208b78 100644 --- a/dlt-connector/src/client/hiero/HieroClient.ts +++ b/dlt-connector/src/client/hiero/HieroClient.ts @@ -1,8 +1,11 @@ import { AccountBalance, AccountBalanceQuery, + AddressBookQuery, Client, + FileId, LocalProvider, + NodeAddressBook, PrivateKey, TopicCreateTransaction, TopicId, @@ -166,6 +169,16 @@ export class HieroClient { return v.parse(hieroIdSchema, createReceipt.topicId?.toString()) } + public async downloadAddressBook(): Promise { + const query = new AddressBookQuery().setFileId(FileId.ADDRESS_BOOK) + try { + return await query.execute(this.client) + } catch (e) { + this.logger.error(e) + throw e + } + } + public async updateTopic(topicId: HieroId): Promise { this.logger.addContext('topicId', topicId.toString()) let transaction = new TopicUpdateTransaction() diff --git a/dlt-connector/src/config/const.ts b/dlt-connector/src/config/const.ts index e0dfc82a3..dc4b0177c 100644 --- a/dlt-connector/src/config/const.ts +++ b/dlt-connector/src/config/const.ts @@ -1,3 +1,21 @@ +import path from 'node:path' + export const LOG4JS_BASE_CATEGORY = 'dlt' // 7 days export const MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_UPDATE = 1000 * 60 * 60 * 24 * 7 +// 10 minutes +export const MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE = 1000 * 60 * 10 + +export const GRADIDO_NODE_RUNTIME_PATH = path.join( + __dirname, + '..', + '..', + 'gradido_node', + 'bin', + 'GradidoNode', +) +// if last start was less than this time, do not restart +export const GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS = 1000 * 30 +export const GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS = 10000 +// currently hard coded in gradido node, update in future +export const GRADIDO_NODE_HOME_FOLDER_NAME = '.gradido' diff --git a/dlt-connector/src/config/schema.ts b/dlt-connector/src/config/schema.ts index 185d0b899..3862a0dd5 100644 --- a/dlt-connector/src/config/schema.ts +++ b/dlt-connector/src/config/schema.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { MemoryBlock } from 'gradido-blockchain-js' import * as v from 'valibot' @@ -73,12 +74,30 @@ export const configSchema = v.object({ ), 500, ), - NODE_SERVER_URL: v.optional( - v.string('The URL of the gradido node server'), - 'http://localhost:6010', + DLT_NODE_SERVER_PORT: v.optional( + v.pipe( + v.string('A valid port on which the DLT node server is running'), + v.transform((input: string) => Number(input)), + v.minValue(1), + v.maxValue(65535), + ), + '8340', ), - BACKEND_SERVER_URL: v.optional( - v.string('The URL of the gradido backend server'), - 'http://localhost:6010', + DLT_GRADIDO_NODE_SERVER_VERSION: v.optional( + v.string('The version of the DLT node server'), + '0.9.0', + ), + DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: v.optional( + v.string('The home folder for the gradido dlt node server'), + path.join(__dirname, '..', '..', 'gradido_node'), + ), + PORT: v.optional( + v.pipe( + v.string('A valid port on which the backend server is running'), + v.transform((input: string) => Number(input)), + v.minValue(1), + v.maxValue(65535), + ), + '4000', ), }) diff --git a/dlt-connector/src/interactions/sendToHiero/SendToHiero.context.ts b/dlt-connector/src/interactions/sendToHiero/SendToHiero.context.ts index 1dd74976c..5b5f1f629 100644 --- a/dlt-connector/src/interactions/sendToHiero/SendToHiero.context.ts +++ b/dlt-connector/src/interactions/sendToHiero/SendToHiero.context.ts @@ -7,6 +7,7 @@ import { } from 'gradido-blockchain-js' import { getLogger } from 'log4js' import * as v from 'valibot' +import { ensureCommunitiesAvailable } from '../../client/GradidoNode/communities' import { HieroClient } from '../../client/hiero/HieroClient' import { LOG4JS_BASE_CATEGORY } from '../../config/const' import { InputTransactionType } from '../../data/InputTransactionType.enum' @@ -21,6 +22,7 @@ import { HieroTransactionIdString, hieroTransactionIdStringSchema, } from '../../schemas/typeGuard.schema' +import { isTopicStillOpen } from '../../utils/hiero' import { AbstractTransactionRole } from './AbstractTransaction.role' import { CommunityRootTransactionRole } from './CommunityRootTransaction.role' import { CreationTransactionRole } from './CreationTransaction.role' @@ -39,13 +41,17 @@ const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.interactions.sendToHiero.SendT export async function SendToHieroContext( input: TransactionInput | CommunityInput, ): Promise { - const role = chooseCorrectRole(input) + const role = await chooseCorrectRole(input) const builder = await role.getGradidoTransactionBuilder() if (builder.isCrossCommunityTransaction()) { // build cross group transaction const outboundTransaction = builder.buildOutbound() validate(outboundTransaction) + if (!isTopicStillOpen(role.getRecipientCommunityTopicId())) { + throw new Error('recipient topic is not open long enough for sending messages') + } + // send outbound transaction to hiero at first, because we need the transaction id for inbound transaction const outboundHieroTransactionIdString = await sendViaHiero( outboundTransaction, @@ -102,9 +108,13 @@ async function sendViaHiero( } // choose correct role based on transaction type and input type -function chooseCorrectRole(input: TransactionInput | CommunityInput): AbstractTransactionRole { +async function chooseCorrectRole( + input: TransactionInput | CommunityInput, +): Promise { const communityParsingResult = v.safeParse(communitySchema, input) if (communityParsingResult.success) { + // make sure gradido node knows community + await ensureCommunitiesAvailable([communityParsingResult.output.hieroTopicId]) return new CommunityRootTransactionRole(communityParsingResult.output) } @@ -116,6 +126,12 @@ function chooseCorrectRole(input: TransactionInput | CommunityInput): AbstractTr }) throw new Error('invalid input') } + // make sure gradido node knows communities + const communityTopicIds = [ + transactionParsingResult.output.user.communityTopicId, + transactionParsingResult.output.linkedUser?.communityTopicId, + ].filter((id): id is HieroId => id !== undefined) + await ensureCommunitiesAvailable(communityTopicIds) const transaction = transactionParsingResult.output switch (transaction.type) { diff --git a/dlt-connector/src/server/index.test.ts b/dlt-connector/src/server/index.test.ts index 9ec7f236a..4b6b8be76 100644 --- a/dlt-connector/src/server/index.test.ts +++ b/dlt-connector/src/server/index.test.ts @@ -25,6 +25,12 @@ mock.module('../KeyPairCacheManager', () => { } }) +mock.module('../client/GradidoNode/communities', () => ({ + ensureCommunitiesAvailable: () => { + return Promise.resolve() + }, +})) + mock.module('../client/hiero/HieroClient', () => ({ HieroClient: { getInstance: () => ({ diff --git a/dlt-connector/src/server/index.ts b/dlt-connector/src/server/index.ts index 406d04d06..191e990d3 100644 --- a/dlt-connector/src/server/index.ts +++ b/dlt-connector/src/server/index.ts @@ -3,6 +3,7 @@ import { Elysia, status, t } from 'elysia' import { AddressType_NONE } from 'gradido-blockchain-js' import { getLogger } from 'log4js' import * as v from 'valibot' +import { ensureCommunitiesAvailable } from '../client/GradidoNode/communities' import { GradidoNodeClient } from '../client/GradidoNode/GradidoNodeClient' import { LOG4JS_BASE_CATEGORY } from '../config/const' import { KeyPairIdentifierLogic } from '../data/KeyPairIdentifier.logic' @@ -125,6 +126,8 @@ async function isAccountExist(identifierAccount: IdentifierAccountInput): Promis // check and prepare input const startTime = Date.now() const identifierAccountParsed = v.parse(identifierAccountSchema, identifierAccount) + // make sure gradido node knows community + await ensureCommunitiesAvailable([identifierAccountParsed.communityTopicId]) const accountKeyPair = await ResolveKeyPair(new KeyPairIdentifierLogic(identifierAccountParsed)) const publicKey = accountKeyPair.getPublicKey() if (!publicKey) { diff --git a/dlt-connector/src/utils/filesystem.ts b/dlt-connector/src/utils/filesystem.ts new file mode 100644 index 000000000..ea2d64e39 --- /dev/null +++ b/dlt-connector/src/utils/filesystem.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs' +import { getLogger } from 'log4js' +import { LOG4JS_BASE_CATEGORY } from '../config/const' + +const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.utils.filesystem`) + +export function checkFileExist(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK) + return true + } catch (_err) { + // logger.debug(`file ${filePath} does not exist: ${_err}`) + return false + } +} + +export function checkPathExist(path: string, createIfMissing: boolean = false): boolean { + const exists = checkFileExist(path) + if (exists) { + return true + } + if (createIfMissing) { + logger.info(`create folder ${path}`) + fs.mkdirSync(path, { recursive: true }) + if (!checkPathExist(path)) { + throw new Error(`Failed to create path ${path}`) + } + } + return false +} diff --git a/dlt-connector/src/utils/hiero.ts b/dlt-connector/src/utils/hiero.ts new file mode 100644 index 000000000..bd1501e83 --- /dev/null +++ b/dlt-connector/src/utils/hiero.ts @@ -0,0 +1,19 @@ +import { HieroClient } from '../client/hiero/HieroClient' +import { MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE } from '../config/const' +import { HieroId } from '../schemas/typeGuard.schema' + +/** + * Checks whether the given topic in the Hedera network will remain open + * for sending messages for at least `MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE` milliseconds. + * + * @param {HieroId} hieroTopicId - The topic ID to check. + * @returns {Promise} `true` if the topic is still open long enough, otherwise `false`. + */ +export async function isTopicStillOpen(hieroTopicId: HieroId): Promise { + const hieroClient = HieroClient.getInstance() + const topicInfo = await hieroClient.getTopicInfo(hieroTopicId) + return ( + topicInfo.expirationTime.getTime() > + new Date().getTime() + MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE + ) +} diff --git a/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts b/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts index 55292cee2..1c7c5587c 100644 --- a/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts +++ b/federation/src/graphql/api/1_0/model/GetPublicCommunityInfoResult.ts @@ -12,6 +12,7 @@ export class GetPublicCommunityInfoResult { this.name = dbCom.name this.description = dbCom.description this.creationDate = dbCom.creationDate + this.hieroTopicId = dbCom.hieroTopicId } @Field(() => String) @@ -28,4 +29,7 @@ export class GetPublicCommunityInfoResult { @Field(() => String) publicJwtKey: string + + @Field(() => String) + hieroTopicId: string | null }