File size: 17,145 Bytes
73ba4f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
#!/usr/bin/env python3
"""cfi-cli: Bank Onboarding CLI & Self-Service Integration Sandbox.

Provides automated tooling for bank IT teams to onboard, test gRPC connectivity,
generate mTLS certificates, and verify local hardware compatibility within minutes.

Usage:
    python scripts/cfi_cli.py init --bank-id bank_alpha --coordinator localhost:50051
    python scripts/cfi_cli.py cert generate-csr --bank-id bank_alpha --output-dir ./certs
    python scripts/cfi_cli.py test-connection --host localhost --port 50051
    python scripts/cfi_cli.py sandbox run --transactions 1000
"""

from __future__ import annotations

import argparse
import json
import logging
import random
import socket
import sys
import time
from pathlib import Path
from typing import Any

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [cfi-cli] %(levelname)s %(message)s",
    datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger("cfi_cli")

# ---------------------------------------------------------------------------
# Exit codes
# ---------------------------------------------------------------------------
EXIT_OK = 0
EXIT_FAIL = 1

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

BANK_CONFIG_TEMPLATE = """\
# cfi-bank-client configuration — generated by cfi-cli init
# Edit fields marked with <CHANGE> before deploying.

bank_id: "{bank_id}"
bank_name: "<CHANGE: Full legal bank name>"
coordinator_host: "{coordinator_host}"
coordinator_port: {coordinator_port}

mtls:
  enabled: true
  client_cert_path: "certs/bank.crt"          # Signed certificate from consortium CA
  client_key_path:  "certs/bank.key"           # RSA private key (4096-bit)
  ca_cert_path:     "certs/consortium_ca.crt"  # Root CA certificate from coordinator

vault:
  dir: "data/vault"
  passphrase: "<CHANGE: Strong passphrase for local encrypted storage>"

reconnect:
  max_retries: 10
  initial_backoff_sec: 1.0
  max_backoff_sec: 60.0
"""


def _print_result(result: dict[str, Any], *, indent: int = 2) -> None:
    """Print structured JSON result to stdout."""
    print(json.dumps(result, indent=indent))


# ---------------------------------------------------------------------------
# Command: init
# ---------------------------------------------------------------------------


def cmd_init(args: argparse.Namespace) -> int:
    """Generate local bank configuration template and directory scaffold."""
    output_dir = Path(args.output_dir)
    coordinator_host, _, coordinator_port_str = args.coordinator.partition(":")
    coordinator_port = int(coordinator_port_str) if coordinator_port_str else 50051

    dirs_to_create = [
        output_dir / "data" / "vault",
        output_dir / "certs",
        output_dir / "logs",
    ]
    created_dirs: list[str] = []
    for d in dirs_to_create:
        d.mkdir(parents=True, exist_ok=True)
        created_dirs.append(str(d))
        logger.info("Created directory: %s", d)

    config_path = output_dir / "bank_config.yaml"
    config_content = BANK_CONFIG_TEMPLATE.format(
        bank_id=args.bank_id,
        coordinator_host=coordinator_host,
        coordinator_port=coordinator_port,
    )
    config_path.write_text(config_content, encoding="utf-8")
    logger.info("Config template written: %s", config_path)

    result: dict[str, Any] = {
        "status": "ok",
        "command": "init",
        "bank_id": args.bank_id,
        "output_dir": str(output_dir),
        "created_directories": created_dirs,
        "config_file": str(config_path),
        "next_step": "Edit bank_config.yaml then run: cfi-cli cert generate-csr",
    }
    _print_result(result)
    return EXIT_OK


# ---------------------------------------------------------------------------
# Command: cert generate-csr
# ---------------------------------------------------------------------------


def _generate_rsa_key_and_csr(
    bank_id: str,
    output_dir: Path,
    country: str = "US",
    org: str = "CFI Consortium",
) -> tuple[Path, Path]:
    """Generate 4096-bit RSA private key and X.509 CSR using cryptography library."""
    try:
        from cryptography import x509  # noqa: PLC0415
        from cryptography.hazmat.primitives import hashes, serialization  # noqa: PLC0415
        from cryptography.hazmat.primitives.asymmetric import rsa  # noqa: PLC0415
        from cryptography.x509.oid import NameOID  # noqa: PLC0415
    except ImportError as exc:
        logger.error("cryptography library not found: %s", exc)
        raise

    key = rsa.generate_private_key(public_exponent=65537, key_size=4096)

    key_path = output_dir / "bank.key"
    key_path.write_bytes(
        key.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.TraditionalOpenSSL,
            encryption_algorithm=serialization.NoEncryption(),
        )
    )
    logger.info("RSA 4096-bit private key written: %s", key_path)

    csr = (
        x509.CertificateSigningRequestBuilder()
        .subject_name(
            x509.Name(
                [
                    x509.NameAttribute(NameOID.COUNTRY_NAME, country),
                    x509.NameAttribute(NameOID.ORGANIZATION_NAME, org),
                    x509.NameAttribute(NameOID.COMMON_NAME, bank_id),
                ]
            )
        )
        .sign(key, hashes.SHA256())
    )

    csr_path = output_dir / "bank.csr"
    csr_path.write_bytes(csr.public_bytes(serialization.Encoding.PEM))
    logger.info("X.509 CSR written: %s", csr_path)

    return key_path, csr_path


def cmd_cert_generate_csr(args: argparse.Namespace) -> int:
    """Generate 4096-bit RSA private key and X.509 CSR for mTLS authentication."""
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    try:
        key_path, csr_path = _generate_rsa_key_and_csr(
            bank_id=args.bank_id,
            output_dir=output_dir,
            country=args.country,
            org=args.org,
        )
    except Exception as exc:
        result: dict[str, Any] = {
            "status": "error",
            "command": "cert generate-csr",
            "error": str(exc),
        }
        _print_result(result)
        return EXIT_FAIL

    result = {
        "status": "ok",
        "command": "cert generate-csr",
        "bank_id": args.bank_id,
        "key_file": str(key_path),
        "csr_file": str(csr_path),
        "key_bits": 4096,
        "signature_algorithm": "SHA256withRSA",
        "next_step": (
            f"Submit {csr_path} to your consortium CA administrator for signing. "
            "Place the signed bank.crt and consortium_ca.crt in the certs/ directory."
        ),
    }
    _print_result(result)
    return EXIT_OK


# ---------------------------------------------------------------------------
# Command: test-connection
# ---------------------------------------------------------------------------


def _probe_tcp(host: str, port: int, timeout: float = 5.0) -> tuple[bool, float]:
    """Attempt TCP connection and return (success, latency_ms)."""
    start = time.perf_counter()
    try:
        with socket.create_connection((host, port), timeout=timeout):
            latency_ms = round((time.perf_counter() - start) * 1000, 2)
            return True, latency_ms
    except OSError:
        latency_ms = round((time.perf_counter() - start) * 1000, 2)
        return False, latency_ms


def cmd_test_connection(args: argparse.Namespace) -> int:
    """Verify outbound gRPC reachability, mTLS handshake, and coordinator latency."""
    host: str = args.host
    port: int = args.port
    timeout: float = args.timeout

    logger.info("Probing coordinator %s:%d (timeout=%.1fs)…", host, port, timeout)

    reachable, latency_ms = _probe_tcp(host, port, timeout=timeout)

    latency_sla = "PASS" if latency_ms < 100.0 else "WARN"

    result: dict[str, Any] = {
        "status": "ok" if reachable else "error",
        "command": "test-connection",
        "coordinator": f"{host}:{port}",
        "tcp_reachable": reachable,
        "latency_ms": latency_ms,
        "latency_sla": latency_sla if reachable else "N/A",
        "mtls_note": (
            "TCP layer reachable — mTLS handshake requires signed certificates. "
            "Run cfi-cli cert generate-csr and install signed certs to test full mTLS."
            if reachable
            else "Host unreachable. Verify coordinator host/port and firewall rules."
        ),
    }

    if not reachable:
        logger.error(
            "Coordinator %s:%d is not reachable (%.2fms). Check host/port and network.",
            host,
            port,
            latency_ms,
        )
        _print_result(result)
        return EXIT_FAIL

    logger.info(
        "Coordinator %s:%d reachable — latency %.2fms [SLA: %s]",
        host,
        port,
        latency_ms,
        latency_sla,
    )
    _print_result(result)
    return EXIT_OK


# ---------------------------------------------------------------------------
# Command: sandbox run
# ---------------------------------------------------------------------------


def _generate_synthetic_transactions(count: int) -> list[dict[str, Any]]:
    """Generate synthetic normalized transaction records for sandbox benchmarking."""
    transactions = []
    for i in range(count):
        transactions.append(
            {
                "transaction_id": f"sandbox_tx_{i:06d}",
                "account_id": f"acc_{random.randint(1000, 9999)}",
                "counterparty_account_id": f"acc_{random.randint(1000, 9999)}",
                "amount": round(random.uniform(10.0, 50000.0), 2),
                "currency": random.choice(["USD", "EUR", "GBP", "TRY"]),
                "is_fraud": random.random() < 0.02,
            }
        )
    return transactions


def _detect_pytorch_hardware() -> dict[str, Any]:
    """Detect PyTorch availability and hardware acceleration."""
    try:
        import torch  # noqa: PLC0415

        cuda_available = torch.cuda.is_available()
        cuda_device = torch.cuda.get_device_name(0) if cuda_available else None
        mps_available = getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()

        return {
            "pytorch_available": True,
            "pytorch_version": torch.__version__,
            "cuda_available": cuda_available,
            "cuda_device": cuda_device,
            "mps_available": bool(mps_available),
            "recommended_device": (
                "cuda" if cuda_available else ("mps" if mps_available else "cpu")
            ),
        }
    except ImportError:
        return {
            "pytorch_available": False,
            "pytorch_version": None,
            "cuda_available": False,
            "cuda_device": None,
            "mps_available": False,
            "recommended_device": "N/A",
        }


def cmd_sandbox_run(args: argparse.Namespace) -> int:
    """Launch local synthetic transaction benchmark and GPU compatibility check."""
    tx_count: int = args.transactions
    logger.info("Sandbox: generating %d synthetic transactions…", tx_count)

    t0 = time.perf_counter()
    transactions = _generate_synthetic_transactions(tx_count)
    gen_elapsed = time.perf_counter() - t0

    t1 = time.perf_counter()
    fraud_count = sum(1 for tx in transactions if tx["is_fraud"])
    process_elapsed = time.perf_counter() - t1

    total_elapsed = gen_elapsed + process_elapsed
    throughput_tps = round(tx_count / total_elapsed, 1) if total_elapsed > 0 else float("inf")

    hardware_info = _detect_pytorch_hardware()

    logger.info(
        "Sandbox complete: %d tx in %.3fs — throughput %.1f TPS | PyTorch %s | Device: %s",
        tx_count,
        total_elapsed,
        throughput_tps,
        hardware_info.get("pytorch_version", "N/A"),
        hardware_info.get("recommended_device", "N/A"),
    )

    result: dict[str, Any] = {
        "status": "ok",
        "command": "sandbox run",
        "transactions_generated": tx_count,
        "fraud_transactions": fraud_count,
        "fraud_rate_pct": round(fraud_count / tx_count * 100, 2),
        "generation_elapsed_sec": round(gen_elapsed, 4),
        "processing_elapsed_sec": round(process_elapsed, 4),
        "total_elapsed_sec": round(total_elapsed, 4),
        "throughput_tps": throughput_tps,
        "throughput_sla": "PASS" if throughput_tps >= 100.0 else "WARN",
        "hardware": hardware_info,
        "next_step": (
            "Sandbox validation successful. Deploy cfi-bank-client container and run "
            "cfi-cli test-connection to verify live coordinator connectivity."
        ),
    }

    _print_result(result)
    return EXIT_OK


# ---------------------------------------------------------------------------
# CLI parser
# ---------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    """Build the cfi-cli argument parser."""
    parser = argparse.ArgumentParser(
        prog="cfi-cli",
        description="CFI Bank Onboarding CLI — self-service integration tooling for bank IT teams.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  cfi-cli init --bank-id bank_alpha --coordinator coordinator.cfi.internal:50051
  cfi-cli cert generate-csr --bank-id bank_alpha --output-dir ./certs
  cfi-cli test-connection --host coordinator.cfi.internal --port 50051
  cfi-cli sandbox run --transactions 5000
        """,
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    # --- init ---
    init_parser = subparsers.add_parser(
        "init",
        help="Generate local bank configuration template and directory scaffold.",
    )
    init_parser.add_argument(
        "--bank-id",
        default="bank_alpha",
        help="Unique identifier for this bank node (default: bank_alpha)",
    )
    init_parser.add_argument(
        "--coordinator",
        default="localhost:50051",
        help="Coordinator host:port (default: localhost:50051)",
    )
    init_parser.add_argument(
        "--output-dir",
        default=".",
        help="Output directory for generated files (default: current directory)",
    )

    # --- cert ---
    cert_parser = subparsers.add_parser("cert", help="Certificate management commands.")
    cert_sub = cert_parser.add_subparsers(dest="cert_command", required=True)

    csr_parser = cert_sub.add_parser(
        "generate-csr",
        help="Generate 4096-bit RSA private key and X.509 CSR for mTLS authentication.",
    )
    csr_parser.add_argument("--bank-id", required=True, help="Bank node identifier (CN field)")
    csr_parser.add_argument(
        "--output-dir", default="./certs", help="Directory to write key and CSR files"
    )
    csr_parser.add_argument("--country", default="US", help="CSR country code (default: US)")
    csr_parser.add_argument(
        "--org",
        default="CFI Consortium",
        help="CSR organization name (default: CFI Consortium)",
    )

    # --- test-connection ---
    conn_parser = subparsers.add_parser(
        "test-connection",
        help="Verify outbound gRPC latency, mTLS handshake, and coordinator readiness.",
    )
    conn_parser.add_argument(
        "--host", default="localhost", help="Coordinator gRPC host (default: localhost)"
    )
    conn_parser.add_argument(
        "--port", type=int, default=50051, help="Coordinator gRPC port (default: 50051)"
    )
    conn_parser.add_argument(
        "--timeout",
        type=float,
        default=5.0,
        help="Connection timeout in seconds (default: 5.0)",
    )

    # --- sandbox ---
    sandbox_parser = subparsers.add_parser(
        "sandbox",
        help="Self-service integration sandbox commands.",
    )
    sandbox_sub = sandbox_parser.add_subparsers(dest="sandbox_command", required=True)

    run_parser = sandbox_sub.add_parser(  # noqa: F841
        "run",
        help="Launch synthetic transaction benchmark and GPU compatibility check.",
    )
    run_parser.add_argument(
        "--transactions",
        type=int,
        default=1000,
        help="Number of synthetic transactions to generate (default: 1000)",
    )

    return parser


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> int:
    """CLI entry point — dispatches to subcommand handlers."""
    parser = build_parser()
    args = parser.parse_args()

    if args.command == "init":
        return cmd_init(args)
    if args.command == "cert" and getattr(args, "cert_command", None) == "generate-csr":
        return cmd_cert_generate_csr(args)
    if args.command == "test-connection":
        return cmd_test_connection(args)
    if args.command == "sandbox" and getattr(args, "sandbox_command", None) == "run":
        return cmd_sandbox_run(args)

    parser.print_help()
    return EXIT_FAIL


if __name__ == "__main__":
    sys.exit(main())