diff options
author | Florian Dold <florian@dold.me> | 2022-12-23 12:59:29 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2022-12-23 13:19:41 +0100 |
commit | 083c4cf5d96314c44dd716cf3cc931e95b651bbd (patch) | |
tree | 7f15a46224d5dfe495e26dc6ec66996c889498ff /packages/taler-harness/src/lint.ts | |
parent | d98711cb51d13bb2da3682014c7c6e75d7fbb4f0 (diff) | |
download | wallet-core-083c4cf5d96314c44dd716cf3cc931e95b651bbd.tar.xz |
spill extra functionality from wallet-cli into taler-harness
We want to keep taler-wallet-cli smaller and have fewer dependencies.
Diffstat (limited to 'packages/taler-harness/src/lint.ts')
-rw-r--r-- | packages/taler-harness/src/lint.ts | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/packages/taler-harness/src/lint.ts b/packages/taler-harness/src/lint.ts new file mode 100644 index 000000000..49fb9dc86 --- /dev/null +++ b/packages/taler-harness/src/lint.ts @@ -0,0 +1,534 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * 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 <dold@taler.net> + */ + +/** + * Imports. + */ +import { + codecForExchangeKeysJson, + codecForKeysManagementResponse, + Configuration, + decodeCrock, +} from "@gnu-taler/taler-util"; +import { + NodeHttpLib, + readSuccessResponseJsonOrThrow, +} from "@gnu-taler/taler-wallet-core"; +import { URL } from "url"; +import { spawn } from "child_process"; +import { delayMs } from "./harness/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<ShellResult> { + 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"); + const ru = roundUnit.required(); + if (ru.currency.toLowerCase() != mainCurrency.toLowerCase()) { + context.numErr++; + console.log( + "error: [TALER]/CURRENCY_ROUND_UNIT: currency does not match main currency", + ); + } + 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<void> { + const cfg = context.cfg; + const accountPrefix = "EXCHANGE-ACCOUNT-"; + const accountCredentialsPrefix = "EXCHANGE-ACCOUNTCREDENTIALS-"; + + let accounts = new Set<string>(); + let credentials = new Set<string>(); + + 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<void> { + 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<void> { + 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); + } +} |