summaryrefslogtreecommitdiff
path: root/packages/taler-harness/src/lint.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-harness/src/lint.ts')
-rw-r--r--packages/taler-harness/src/lint.ts536
1 files changed, 536 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..a45e6db9d
--- /dev/null
+++ b/packages/taler-harness/src/lint.ts
@@ -0,0 +1,536 @@
+/*
+ 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 { URL } from "url";
+import { spawn } from "child_process";
+import { delayMs } from "./harness/harness.js";
+import {
+ createPlatformHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+
+interface BasicConf {
+ mainCurrency: string;
+}
+
+interface PubkeyConf {
+ masterPublicKey: string;
+}
+
+const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+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.isDefined()) {
+ context.numErr++;
+ console.log("error: currency not defined in section TALER option CURRENCY");
+ console.log("Aborting further checks.");
+ process.exit(1);
+ } else {
+ mainCurrency = currencyEntry.required().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.fetch(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.fetch(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.fetch(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);
+ }
+}