diff --git a/make_post_sell/lib/crypto_watcher/wallet_dist_config.py b/make_post_sell/lib/crypto_watcher/wallet_dist_config.py new file mode 100644 index 0000000..7a1960e --- /dev/null +++ b/make_post_sell/lib/crypto_watcher/wallet_dist_config.py @@ -0,0 +1,137 @@ +""" +Read wallet-dist config from production.ini and build kwargs for the +``ErlangDist*Client`` constructors in ``erldist_clients``. + +Config shape (added to ``[app:main]`` in production.ini by Salt/pillar): + + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@my.makepostsell.com + wallet_dist.peer_name = wallet + wallet_dist.peer_host = cammy.foxhop.net + wallet_dist.registered_name = Elixir.Wallet.Service + wallet_dist.cert = /etc/make_post_sell/wallet-dist/mps-client.crt + wallet_dist.key = /etc/make_post_sell/wallet-dist/mps-client.key + wallet_dist.ca = /etc/make_post_sell/wallet-dist/ca.crt + wallet_dist.cookie_file = /etc/make_post_sell/wallet-dist/cookie + +The cookie value is read from ``cookie_file`` at config-load time. The +caller never sees the value on stdout / args / logs — it stays inside +the WalletDistConfig dataclass and gets passed straight to the client +constructor. +""" + +from __future__ import annotations + +from configparser import ConfigParser +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class WalletDistConfig: + enabled: bool + our_node_name: str + peer_name: str + peer_host: str + registered_name: str + tls_cert: Optional[str] + tls_key: Optional[str] + tls_ca: Optional[str] + cookie: str + + def client_kwargs(self) -> dict: + """Kwargs for ErlangDistMoneroClient / ErlangDist*Client. + + Caller is responsible for closing the client (or using it as a + context manager).""" + return dict( + our_name=self.our_node_name, + peer_name=self.peer_name, + peer_host=self.peer_host, + cookie=self.cookie, + tls_cert=self.tls_cert, + tls_key=self.tls_key, + tls_ca=self.tls_ca, + ) + + +def _truthy(value: Optional[str]) -> bool: + return (value or "").strip().lower() in ("true", "1", "yes", "on") + + +def load_wallet_dist_config(ini_path: str) -> WalletDistConfig: + """Read ``[app:main]`` from ``ini_path`` and assemble a WalletDistConfig. + + Raises ``ValueError`` if ``wallet_dist.enabled = true`` but any of the + required keys / files are missing. Returns a disabled config (no + side-effects) if ``wallet_dist.enabled`` is unset or false. + """ + cp = ConfigParser() + if not cp.read(ini_path): + raise FileNotFoundError(f"production.ini not found: {ini_path}") + if "app:main" not in cp: + raise ValueError(f"{ini_path} has no [app:main] section") + app = cp["app:main"] + + enabled = _truthy(app.get("wallet_dist.enabled")) + if not enabled: + return WalletDistConfig( + enabled=False, + our_node_name="", + peer_name="", + peer_host="", + registered_name="", + tls_cert=None, + tls_key=None, + tls_ca=None, + cookie="", + ) + + required = ( + "wallet_dist.our_node_name", + "wallet_dist.peer_name", + "wallet_dist.peer_host", + "wallet_dist.cookie_file", + ) + missing = [k for k in required if not app.get(k, "").strip()] + if missing: + raise ValueError( + f"wallet_dist enabled but missing keys: {', '.join(missing)}" + ) + + cookie_file = app["wallet_dist.cookie_file"].strip() + try: + cookie = Path(cookie_file).read_text().strip() + except OSError as e: + raise ValueError(f"cannot read wallet_dist.cookie_file {cookie_file!r}: {e}") + if not cookie: + raise ValueError(f"wallet_dist.cookie_file is empty: {cookie_file}") + + # TLS material — all three or none. None = plain inet_tcp_dist + # (acceptable when peer also runs plaintext, e.g. dev). All three = + # inet_tls_dist (production via cammy cluster). + cert = (app.get("wallet_dist.cert") or "").strip() or None + key = (app.get("wallet_dist.key") or "").strip() or None + ca = (app.get("wallet_dist.ca") or "").strip() or None + tls_count = sum(1 for v in (cert, key, ca) if v) + if tls_count not in (0, 3): + raise ValueError( + "wallet_dist.cert/key/ca must be all set or all empty " + f"(got cert={bool(cert)} key={bool(key)} ca={bool(ca)})" + ) + + return WalletDistConfig( + enabled=True, + our_node_name=app["wallet_dist.our_node_name"].strip(), + peer_name=app["wallet_dist.peer_name"].strip(), + peer_host=app["wallet_dist.peer_host"].strip(), + registered_name=( + app.get("wallet_dist.registered_name", "Elixir.Wallet.Service").strip() + or "Elixir.Wallet.Service" + ), + tls_cert=cert, + tls_key=key, + tls_ca=ca, + cookie=cookie, + ) diff --git a/make_post_sell/lib/crypto_watcher/wallet_dist_health.py b/make_post_sell/lib/crypto_watcher/wallet_dist_health.py new file mode 100644 index 0000000..3978b34 --- /dev/null +++ b/make_post_sell/lib/crypto_watcher/wallet_dist_health.py @@ -0,0 +1,104 @@ +""" +``mps_wallet_dist_health`` — single-shot smoke test that MPS can reach +``Wallet.Service`` on the cluster via Erlang dist. + +Usage:: + + mps_wallet_dist_health /opt/make_post_sell/production.ini + +Reads the ``wallet_dist`` keys from ``[app:main]``, opens a dist +connection via erldistpy, calls a handful of cheap operations +(``health`` checks against the wallet node), prints results. + +Exit code: + 0 if connection + RPC succeeded + 2 if config is missing / wallet_dist not enabled + 3 if the dist connection or RPC call failed (likely cert/cookie + mismatch, network reach, or wallet@cammy down — log line above + will name the failure) +""" + +from __future__ import annotations + +import sys +import traceback + +import click + +from make_post_sell.lib.crypto_watcher.wallet_dist_config import ( + WalletDistConfig, + load_wallet_dist_config, +) + + +@click.command(name="mps-wallet-dist-health") +@click.argument( + "config_path", + type=click.Path(dir_okay=False, exists=True, readable=True), + default="/opt/make_post_sell/production.ini", +) +@click.option( + "--timeout", + type=float, + default=10.0, + show_default=True, + help="dist call timeout in seconds.", +) +def cli(config_path: str, timeout: float) -> None: + """Verify MPS can talk to wallet@cammy over Erlang dist.""" + try: + cfg = load_wallet_dist_config(config_path) + except Exception as e: + click.echo(f"config error: {e}", err=True) + raise SystemExit(2) from None + + if not cfg.enabled: + click.echo("wallet_dist.enabled = false (nothing to test)", err=True) + raise SystemExit(2) + + click.echo("=== MPS wallet-dist health check ===") + click.echo(f"our_node_name : {cfg.our_node_name}") + click.echo(f"peer : {cfg.peer_name}@{cfg.peer_host}") + click.echo(f"registered_name : {cfg.registered_name}") + click.echo(f"tls : {'mTLS' if cfg.tls_cert else 'plain'}") + click.echo("") + + # Local import — erldistpy is a runtime-optional dep (only required + # when wallet_dist is enabled). Importing at module top would force + # the dep on every MPS process, including ones that never touch dist. + try: + from make_post_sell.lib.crypto_watcher.erldist_clients import ( + ErlangDistMoneroClient, + ) + except ImportError as e: + click.echo(f"erldistpy not installed: {e}", err=True) + click.echo("hint: pip install 'erldistpy>=0.1.6'", err=True) + raise SystemExit(3) from None + + try: + with ErlangDistMoneroClient( + **cfg.client_kwargs(), + call_timeout=timeout, + ) as client: + click.echo("connected ✓") + height = client.get_height() + click.echo(f"monero height: {height}") + synced = client.is_synced() + click.echo(f"synced: {synced}") + except Exception as e: + click.echo(f"\nDIST RPC FAILED: {type(e).__name__}: {e}", err=True) + click.echo("traceback:", err=True) + traceback.print_exc() + raise SystemExit(3) from None + + click.echo("") + click.echo("OK — MPS can reach Wallet.Service on the cluster ✓") + + +def main() -> None: + # The console_script entry point. Click handles arg parsing. + cli(standalone_mode=True) # standard click flow + + +if __name__ == "__main__": # pragma: no cover + cli(standalone_mode=True) diff --git a/make_post_sell/tests/test_wallet_dist_config.py b/make_post_sell/tests/test_wallet_dist_config.py new file mode 100644 index 0000000..1c74667 --- /dev/null +++ b/make_post_sell/tests/test_wallet_dist_config.py @@ -0,0 +1,185 @@ +"""Tests for ``wallet_dist_config`` — production.ini → WalletDistConfig.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from make_post_sell.lib.crypto_watcher.wallet_dist_config import ( + load_wallet_dist_config, +) + + +def _write_ini(tmp_path: Path, body: str) -> Path: + p = tmp_path / "test.ini" + p.write_text(textwrap.dedent(body).lstrip()) + return p + + +def _write_cookie(tmp_path: Path, value: str = "TEST_COOKIE_VALUE") -> Path: + p = tmp_path / "cookie" + p.write_text(value) + return p + + +class TestDisabled: + def test_disabled_when_key_absent(self, tmp_path): + ini = _write_ini(tmp_path, """ + [app:main] + unused = x + """) + cfg = load_wallet_dist_config(str(ini)) + assert cfg.enabled is False + + def test_disabled_when_value_false(self, tmp_path): + ini = _write_ini(tmp_path, """ + [app:main] + wallet_dist.enabled = false + """) + cfg = load_wallet_dist_config(str(ini)) + assert cfg.enabled is False + + +class TestEnabled: + def test_full_config_with_tls(self, tmp_path): + cookie = _write_cookie(tmp_path) + cert = tmp_path / "client.crt" + cert.write_text("cert-pem") + key = tmp_path / "client.key" + key.write_text("key-pem") + ca = tmp_path / "ca.crt" + ca.write_text("ca-pem") + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@my.makepostsell.com + wallet_dist.peer_name = wallet + wallet_dist.peer_host = cammy.foxhop.net + wallet_dist.registered_name = Elixir.Wallet.Service + wallet_dist.cert = {cert} + wallet_dist.key = {key} + wallet_dist.ca = {ca} + wallet_dist.cookie_file = {cookie} + """) + cfg = load_wallet_dist_config(str(ini)) + assert cfg.enabled is True + assert cfg.our_node_name == "mps@my.makepostsell.com" + assert cfg.peer_name == "wallet" + assert cfg.peer_host == "cammy.foxhop.net" + assert cfg.registered_name == "Elixir.Wallet.Service" + assert cfg.tls_cert == str(cert) + assert cfg.tls_key == str(key) + assert cfg.tls_ca == str(ca) + assert cfg.cookie == "TEST_COOKIE_VALUE" + + def test_plaintext_dist_no_tls(self, tmp_path): + """All three TLS keys empty = plain inet_tcp_dist.""" + cookie = _write_cookie(tmp_path) + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = localhost + wallet_dist.cookie_file = {cookie} + """) + cfg = load_wallet_dist_config(str(ini)) + assert cfg.enabled is True + assert cfg.tls_cert is None + assert cfg.tls_key is None + assert cfg.tls_ca is None + + def test_registered_name_defaults(self, tmp_path): + cookie = _write_cookie(tmp_path) + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = localhost + wallet_dist.cookie_file = {cookie} + """) + cfg = load_wallet_dist_config(str(ini)) + assert cfg.registered_name == "Elixir.Wallet.Service" + + def test_client_kwargs_match_erldist_client_signature(self, tmp_path): + cookie = _write_cookie(tmp_path, "cookie-1") + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = remote + wallet_dist.cookie_file = {cookie} + """) + cfg = load_wallet_dist_config(str(ini)) + kw = cfg.client_kwargs() + # Required keys for ErlangDistMoneroClient/UTXO subclasses + assert kw["our_name"] == "mps@host" + assert kw["peer_name"] == "wallet" + assert kw["peer_host"] == "remote" + assert kw["cookie"] == "cookie-1" + assert kw["tls_cert"] is None + assert kw["tls_key"] is None + assert kw["tls_ca"] is None + + +class TestErrors: + def test_missing_ini(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_wallet_dist_config(str(tmp_path / "nope.ini")) + + def test_missing_required_key(self, tmp_path): + cookie = _write_cookie(tmp_path) + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.peer_name = wallet + wallet_dist.peer_host = host + wallet_dist.cookie_file = {cookie} + """) + with pytest.raises(ValueError, match="our_node_name"): + load_wallet_dist_config(str(ini)) + + def test_cookie_file_unreadable(self, tmp_path): + ini = _write_ini(tmp_path, """ + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = host + wallet_dist.cookie_file = /nonexistent/path/cookie + """) + with pytest.raises(ValueError, match="cookie_file"): + load_wallet_dist_config(str(ini)) + + def test_empty_cookie_file(self, tmp_path): + cookie = _write_cookie(tmp_path, "") + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = host + wallet_dist.cookie_file = {cookie} + """) + with pytest.raises(ValueError, match="empty"): + load_wallet_dist_config(str(ini)) + + def test_partial_tls_config_rejected(self, tmp_path): + """Setting only cert without key/ca is config-time error, + not a deferred-at-connect crash.""" + cookie = _write_cookie(tmp_path) + ini = _write_ini(tmp_path, f""" + [app:main] + wallet_dist.enabled = true + wallet_dist.our_node_name = mps@host + wallet_dist.peer_name = wallet + wallet_dist.peer_host = host + wallet_dist.cert = /some/path + wallet_dist.cookie_file = {cookie} + """) + with pytest.raises(ValueError, match="cert/key/ca"): + load_wallet_dist_config(str(ini)) diff --git a/setup.py b/setup.py index 7229f4e..de05dbe 100644 --- a/setup.py +++ b/setup.py @@ -85,6 +85,7 @@ setup( "initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main", "crypto_watcher = make_post_sell.lib.crypto_watcher:main", "digest_sender = make_post_sell.lib.digest_sender:main", + "mps_wallet_dist_health = make_post_sell.lib.crypto_watcher.wallet_dist_health:main", ], }, )