/* This file is part of GNU Taler (C) 2021 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * The deployment linter implements checks for a deployment * of the GNU Taler exchange. It is meant to help sysadmins * when setting up an exchange. * * The linter does checks in the configuration and uses * various tools of the exchange in test mode (-t). * * To be able to run the tools as the right user, the linter should be * run as root. * * @author Florian Dold */ /** * Imports. */ import { codecForExchangeKeysJson, codecForKeysManagementResponse, Configuration, } from "@gnu-taler/taler-util"; import { decodeCrock, NodeHttpLib, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-wallet-core"; import { URL } from "url"; import { spawn } from "child_process"; import { delayMs } from "./integrationtests/harness.js"; interface BasicConf { mainCurrency: string; } interface PubkeyConf { masterPublicKey: string; } const httpLib = new NodeHttpLib(); interface ShellResult { stdout: string; stderr: string; status: number; } interface LintContext { /** * Be more verbose. */ verbose: boolean; /** * Always continue even after errors. */ cont: boolean; cfg: Configuration; numErr: number; } /** * Run a shell command, return stdout. */ export async function sh( context: LintContext, command: string, env: { [index: string]: string | undefined } = process.env, ): Promise { if (context.verbose) { console.log("executing command:", command); } return new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const proc = spawn(command, { stdio: ["inherit", "pipe", "pipe"], shell: true, env: env, }); proc.stdout.on("data", (x) => { if (x instanceof Buffer) { stdoutChunks.push(x); } else { throw Error("unexpected data chunk type"); } }); proc.stderr.on("data", (x) => { if (x instanceof Buffer) { stderrChunks.push(x); } else { throw Error("unexpected data chunk type"); } }); proc.on("exit", (code, signal) => { if (code != 0 && context.verbose) { console.log(`child process exited (${code} / ${signal})`); } const bOut = Buffer.concat(stdoutChunks).toString("utf-8"); const bErr = Buffer.concat(stderrChunks).toString("utf-8"); resolve({ status: code ?? -1, stderr: bErr, stdout: bOut, }); }); proc.on("error", () => { reject(Error("Child process had error")); }); }); } function checkBasicConf(context: LintContext): BasicConf { const cfg = context.cfg; const currencyEntry = cfg.getString("taler", "currency"); let mainCurrency: string | undefined; if (!currencyEntry.value) { context.numErr++; console.log("error: currency not defined in section TALER option CURRENCY"); console.log("Aborting further checks."); process.exit(1); } else { mainCurrency = currencyEntry.value.toUpperCase(); } if (mainCurrency === "KUDOS") { console.log( "warning: section TALER option CURRENCY contains toy currency value KUDOS", ); } const roundUnit = cfg.getAmount("taler", "currency_round_unit"); if (!roundUnit.isDefined) { context.numErr++; console.log( "error: configuration incomplete, section TALER option CURRENCY_ROUND_UNIT missing", ); } return { mainCurrency }; } function checkCoinConfig(context: LintContext, basic: BasicConf): void { const cfg = context.cfg; const coinPrefix1 = "COIN_"; const coinPrefix2 = "COIN-"; let numCoins = 0; for (const secName of cfg.getSectionNames()) { if (!(secName.startsWith(coinPrefix1) || secName.startsWith(coinPrefix2))) { continue; } numCoins++; // FIXME: check that section is well-formed } if (numCoins == 0) { context.numErr++; console.log( "error: no coin denomination configured, please configure [coin-*] sections", ); } } async function checkWireConfig(context: LintContext): Promise { const cfg = context.cfg; const accountPrefix = "EXCHANGE-ACCOUNT-"; const accountCredentialsPrefix = "EXCHANGE-ACCOUNTCREDENTIALS-"; let accounts = new Set(); let credentials = new Set(); for (const secName of cfg.getSectionNames()) { if (secName.startsWith(accountPrefix)) { accounts.add(secName.slice(accountPrefix.length)); // FIXME: check settings } if (secName.startsWith(accountCredentialsPrefix)) { credentials.add(secName.slice(accountCredentialsPrefix.length)); // FIXME: check settings } } if (accounts.size === 0) { context.numErr++; console.log( "error: No accounts configured (no sections EXCHANGE_ACCOUNT-*).", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } for (const acc of accounts) { if (!credentials.has(acc)) { console.log( `warning: no credentials configured for exchange-account-${acc}`, ); } } for (const acc of accounts) { // test credit history { const res = await sh( context, "su -l --shell /bin/sh " + `-c 'taler-exchange-wire-gateway-client -s exchange-accountcredentials-${acc} --credit-history'` + "taler-exchange-wire", ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log( "error: Could not run taler-exchange-wire-gateway-client. Please review logs above.", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } } // TWG client { const res = await sh( context, `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`, ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log("error: Could not run wirewatch. Please review logs above."); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } // Wirewatch { const res = await sh( context, `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`, ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log("error: Could not run wirewatch. Please review logs above."); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } // Closer { const res = await sh( context, `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`, ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log("error: Could not run closer. Please review logs above."); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } } async function checkAggregatorConfig(context: LintContext) { const res = await sh( context, "su -l --shell /bin/sh -c 'taler-exchange-aggregator -t' taler-exchange-aggregator", ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log("error: Could not run aggregator. Please review logs above."); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } async function checkCloserConfig(context: LintContext) { const res = await sh( context, `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`, ); if (res.status != 0) { context.numErr++; console.log(res.stdout); console.log(res.stderr); console.log("error: Could not run closer. Please review logs above."); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } function checkMasterPublicKeyConfig(context: LintContext): PubkeyConf { const cfg = context.cfg; const pub = cfg.getString("exchange", "master_public_key"); const pubDecoded = decodeCrock(pub.required()); if (pubDecoded.length != 32) { context.numErr++; console.log("error: invalid master public key"); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } return { masterPublicKey: pub.required(), }; } export async function checkExchangeHttpd( context: LintContext, pubConf: PubkeyConf, ): Promise { const cfg = context.cfg; const baseUrlEntry = cfg.getString("exchange", "base_url"); if (!baseUrlEntry.isDefined) { context.numErr++; console.log( "error: configuration needs to specify section EXCHANGE option BASE_URL", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } const baseUrl = baseUrlEntry.required(); if (!baseUrl.startsWith("http")) { context.numErr++; console.log( "error: section EXCHANGE option BASE_URL needs to be an http or https URL", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } if (!baseUrl.endsWith("/")) { context.numErr++; console.log( "error: section EXCHANGE option BASE_URL needs to end with a slash", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } if (!baseUrl.startsWith("https://")) { console.log( "warning: section EXCHANGE option BASE_URL: it is recommended to serve the exchange via HTTPS", ); } { const mgmtUrl = new URL("management/keys", baseUrl); const resp = await httpLib.get(mgmtUrl.href); const futureKeys = await readSuccessResponseJsonOrThrow( resp, codecForKeysManagementResponse(), ); if (futureKeys.future_denoms.length > 0) { console.log( `warning: exchange has denomination keys that need to be signed by the offline signing procedure`, ); } if (futureKeys.future_signkeys.length > 0) { console.log( `warning: exchange has signing keys that need to be signed by the offline signing procedure`, ); } } // Check if we can use /keys already { const keysUrl = new URL("keys", baseUrl); const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]); if (!resp) { context.numErr; console.log( "error: request to /keys timed out. " + "Make sure to sign and upload denomination and signing keys " + "with taler-exchange-offline.", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } else { const keys = await readSuccessResponseJsonOrThrow( resp, codecForExchangeKeysJson(), ); if (keys.master_public_key !== pubConf.masterPublicKey) { context.numErr; console.log( "error: master public key of exchange does not match public key of live exchange", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } } } // Check /wire { const keysUrl = new URL("wire", baseUrl); const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]); if (!resp) { context.numErr++; console.log( "error: request to /wire timed out. " + "Make sure to sign and upload accounts and wire fees " + "using the taler-exchange-offline tool.", ); if (!context.cont) { console.log("Aborting further checks."); process.exit(1); } } else { if (resp.status !== 200) { console.log( "error: Can't access exchange /wire. Please check " + "the logs of taler-exchange-httpd for further information.", ); } } } } /** * Do some basic checks in the configuration of a Taler deployment. */ export async function lintExchangeDeployment( verbose: boolean, cont: boolean, ): Promise { if (process.getuid() != 0) { console.log( "warning: the exchange deployment linter is designed to be run as root", ); } const cfg = Configuration.load(); const context: LintContext = { cont, verbose, cfg, numErr: 0, }; const basic = checkBasicConf(context); checkCoinConfig(context, basic); await checkWireConfig(context); await checkAggregatorConfig(context); await checkCloserConfig(context); const pubConf = checkMasterPublicKeyConfig(context); await checkExchangeHttpd(context, pubConf); if (context.numErr == 0) { console.log("Linting completed without errors."); process.exit(0); } else { console.log(`Linting completed with ${context.numErr} errors.`); process.exit(1); } }