ipmg 2.1.1__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {ipmg-2.1.1/src/ipmg.egg-info → ipmg-2.3.0}/PKG-INFO +66 -6
  2. {ipmg-2.1.1 → ipmg-2.3.0}/README.md +64 -5
  3. {ipmg-2.1.1 → ipmg-2.3.0}/pyproject.toml +5 -1
  4. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/commands.py +11 -0
  6. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/parser.py +130 -1
  7. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/diff.py +6 -0
  8. ipmg-2.3.0/src/ipmg/core/health.py +64 -0
  9. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/exceptions.py +4 -0
  10. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/incremental.py +213 -5
  11. ipmg-2.3.0/src/ipmg/infrastructure/notify.py +403 -0
  12. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/ui.py +2 -1
  13. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/scan_service.py +88 -10
  14. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/app.py +218 -39
  15. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/manager.py +18 -16
  16. ipmg-2.3.0/src/ipmg/web/schemas.py +239 -0
  17. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/server.py +1 -1
  18. {ipmg-2.1.1 → ipmg-2.3.0/src/ipmg.egg-info}/PKG-INFO +66 -6
  19. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/SOURCES.txt +6 -0
  20. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/requires.txt +1 -0
  21. ipmg-2.3.0/tests/test_api_contract.py +153 -0
  22. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_commands.py +50 -0
  23. ipmg-2.3.0/tests/test_health.py +63 -0
  24. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_incremental.py +163 -0
  25. ipmg-2.3.0/tests/test_notify.py +520 -0
  26. ipmg-2.3.0/tests/test_parser.py +140 -0
  27. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_scan_service.py +140 -0
  28. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_api.py +54 -0
  29. ipmg-2.1.1/tests/test_parser.py +0 -57
  30. {ipmg-2.1.1 → ipmg-2.3.0}/LICENSE +0 -0
  31. {ipmg-2.1.1 → ipmg-2.3.0}/setup.cfg +0 -0
  32. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/__main__.py +0 -0
  33. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/__init__.py +0 -0
  34. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/__init__.py +0 -0
  35. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/discovery.py +0 -0
  36. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/engine.py +0 -0
  37. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/ping.py +0 -0
  38. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/portscan.py +0 -0
  39. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/security.py +0 -0
  40. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/__init__.py +0 -0
  41. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/database.py +0 -0
  42. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/file_io.py +0 -0
  43. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/__init__.py +0 -0
  44. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/diff_report.py +0 -0
  45. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/frames.py +0 -0
  46. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/live.py +0 -0
  47. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/summary.py +0 -0
  48. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/__init__.py +0 -0
  49. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/history_service.py +0 -0
  50. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/utils/__init__.py +0 -0
  51. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/utils/helpers.py +0 -0
  52. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/__init__.py +0 -0
  53. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/db.py +0 -0
  54. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/css/app.css +0 -0
  55. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/index.html +0 -0
  56. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/api.js +0 -0
  57. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/app.js +0 -0
  58. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/charts.js +0 -0
  59. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/demo.js +0 -0
  60. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/views.js +0 -0
  61. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
  62. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/entry_points.txt +0 -0
  63. {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/top_level.txt +0 -0
  64. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_database_history.py +0 -0
  65. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_diff.py +0 -0
  66. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_diff_report.py +0 -0
  67. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_discover.py +0 -0
  68. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_engine.py +0 -0
  69. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_file_io.py +0 -0
  70. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_history_service.py +0 -0
  71. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_live.py +0 -0
  72. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ping.py +0 -0
  73. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ping_command.py +0 -0
  74. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_portscan.py +0 -0
  75. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ui.py +0 -0
  76. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_utils.py +0 -0
  77. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_db.py +0 -0
  78. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_manager.py +0 -0
  79. {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 2.1.1
3
+ Version: 2.3.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -33,6 +33,7 @@ Requires-Dist: pandas>=2.2.2
33
33
  Requires-Dist: openpyxl>=3.1
34
34
  Requires-Dist: rich>=13.0
35
35
  Requires-Dist: fastapi>=0.110
36
+ Requires-Dist: pydantic>=2.7
36
37
  Requires-Dist: uvicorn>=0.27
37
38
  Requires-Dist: websockets>=12
38
39
  Requires-Dist: python-multipart>=0.0.9
@@ -390,6 +391,21 @@ ipmg diff --fail-on-change # exit 2 when anything changed (CI)
390
391
  ipmg history --limit 10 # list stored scans
391
392
  ```
392
393
 
394
+ To hear about changes without watching the terminal, send them to Slack,
395
+ Microsoft Teams, any JSON webhook, or email. Any `--notify-*` flag turns on
396
+ `--compare`, and with `--interval` every pass that changes something alerts:
397
+
398
+ ```bash
399
+ ipmg --input targets.txt --interval 15 --notify-slack # URL from $IPMG_NOTIFY_SLACK
400
+ ipmg --input targets.txt --notify-email ops@example.com --smtp-host smtp.example.com
401
+ ipmg diff --notify-webhook https://ops.example.com/ipmg --notify-severity critical
402
+ ```
403
+
404
+ Only changes at or above `--notify-severity` (default `warning`) trigger a
405
+ notification. A notification that fails is reported but never fails the scan.
406
+ Secrets such as webhook URLs and `IPMG_SMTP_PASSWORD` can come from environment
407
+ variables; see [Notifications](docs/COMMANDS.md#notifications) for all of them.
408
+
393
409
  What counts as a change:
394
410
 
395
411
  | Change | Severity | Meaning |
@@ -422,6 +438,11 @@ source**, so file-based and `--discover` runs do not get mixed up. Pass
422
438
  | `--latency-threshold` | `5` | Minimum latency delta in ms |
423
439
  | `--latency-pct` | `25` | Minimum relative latency change |
424
440
  | `--fail-on-change` | off | `ipmg diff` exits 2 when changes are found |
441
+ | `--notify-webhook` | off | POST the change report as JSON to a URL |
442
+ | `--notify-slack` | off | Post changes to a Slack incoming webhook |
443
+ | `--notify-teams` | off | Post changes to a Microsoft Teams Workflows webhook |
444
+ | `--notify-email` | off | Email the Markdown change report (with `--smtp-host`, `--smtp-port`, `--smtp-security`, `--smtp-user`, `--smtp-from`) |
445
+ | `--notify-severity` | `warning` | Only notify for changes at least this severe |
425
446
 
426
447
  ---
427
448
 
@@ -462,6 +483,14 @@ package, nothing is loaded from a CDN. It gives you:
462
483
  | `--no-browser` | off | Don't open the browser automatically |
463
484
  | `--db` | `~/.ipmg/dashboard.db` | History database location |
464
485
 
486
+ ### Scripting IPMG Web
487
+
488
+ Everything the dashboard does goes through a documented, versioned REST API
489
+ under `/api/v1`, so you can start scans, fetch results, and compare scans from
490
+ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)
491
+ covers authentication, errors, and worked `curl` examples; the running server
492
+ also serves interactive docs at `http://127.0.0.1:8080/docs`.
493
+
465
494
  ### On a server with no browser
466
495
 
467
496
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -542,6 +571,21 @@ default). A finished scan overwrites those files with the complete report, so
542
571
  the file names and contents are the same as they always were. Use
543
572
  `--no-incremental` to go back to writing only at the end.
544
573
 
574
+ **Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
575
+ partial report already holds, scans only the rest, and finishes that same
576
+ report — same file names, same batch timestamp:
577
+
578
+ ```bash
579
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
580
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
581
+ ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
582
+ ```
583
+
584
+ Without a path, `--resume` takes the newest report named after `--output`,
585
+ preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
586
+ (current to the last autosave). Hosts dropped from the target list since are
587
+ left out of the finished report.
588
+
545
589
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
546
590
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
547
591
  HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
@@ -572,6 +616,7 @@ them — so piping IPMG into a file or a log gives you clean text.
572
616
  | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
573
617
  | `--no-incremental` | off | Only write the report once the scan has finished |
574
618
  | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
619
+ | `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
575
620
 
576
621
  **Speed and accuracy**
577
622
 
@@ -601,12 +646,21 @@ them — so piping IPMG into a file or a log gives you clean text.
601
646
  | `--stream-refresh` | `0.25` | Seconds between progress-bar redraws while streaming (0.05-5) |
602
647
  | `--verbose` | off | Debug logging |
603
648
 
649
+ **Exit status**
650
+
651
+ | Flag | Default | Description |
652
+ | --- | --- | --- |
653
+ | `--fail-on-down` | off | Exit 3 if any target is not `Active` (reports are still written) |
654
+ | `--min-active` | off | Exit 3 if fewer than this percentage of targets are `Active` |
655
+
604
656
  **History and changes** — see [Change detection](#change-detection) for
605
657
  `--compare`, `--no-history`, `--db`, `--diff-formats`, `--diff-output`,
606
- `--latency-threshold`, `--latency-pct`, and `--fail-on-change`.
658
+ `--latency-threshold`, `--latency-pct`, `--fail-on-change`, and the
659
+ `--notify-*` flags.
607
660
 
608
661
  Exit codes: `0` success, `1` error, `2` changes detected
609
- (`ipmg diff --fail-on-change`), `130` interrupted.
662
+ (`ipmg diff --fail-on-change`), `3` hosts down (`--fail-on-down`,
663
+ `--min-active`), `130` interrupted.
610
664
 
611
665
  ---
612
666
 
@@ -627,12 +681,15 @@ itself is hardened accordingly:
627
681
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
628
682
  so a bad input file cannot exhaust memory
629
683
  - All database access uses parameterized SQL
684
+ - The only outbound requests IPMG makes are the notifications you ask for
685
+ with a `--notify-*` flag. Their URLs and the SMTP password can come from
686
+ environment variables, and error messages never repeat them
630
687
 
631
688
  If you bind to a non-local address with `--host`, the token still guards the
632
689
  API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
633
690
  proxy with TLS on any network you don't trust.
634
691
 
635
- Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
692
+ Found a vulnerability? See [SECURITY.md](.github/SECURITY.md) for how to report it.
636
693
 
637
694
  ---
638
695
 
@@ -641,6 +698,9 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
641
698
  - **[Command reference](https://github.com/sameeralam3127/ipmg/blob/main/docs/COMMANDS.md)** —
642
699
  every command and flag with copy-paste examples, a safe session that tries
643
700
  everything on your own machine, exit codes, and error messages
701
+ - **[Web API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)** —
702
+ script IPMG Web over HTTP: start scans, fetch results, compare scans,
703
+ and follow live events
644
704
  - **[Troubleshooting](https://github.com/sameeralam3127/ipmg/blob/main/docs/TROUBLESHOOTING.md)** —
645
705
  install errors, `command not found`, every host timing out, slow scans,
646
706
  rejected input files
@@ -654,11 +714,11 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
654
714
  ## Contributing
655
715
 
656
716
  Contributions are welcome.
657
- [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/CONTRIBUTING.md)
717
+ [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/.github/CONTRIBUTING.md)
658
718
  covers setting up a development environment, running the tests, the commit
659
719
  message format that drives automated releases, and how the project website and
660
720
  IPMG Web demo are built. Everyone taking part is expected to follow the
661
- [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/CODE_OF_CONDUCT.md).
721
+ [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/.github/CODE_OF_CONDUCT.md).
662
722
 
663
723
  ---
664
724
 
@@ -343,6 +343,21 @@ ipmg diff --fail-on-change # exit 2 when anything changed (CI)
343
343
  ipmg history --limit 10 # list stored scans
344
344
  ```
345
345
 
346
+ To hear about changes without watching the terminal, send them to Slack,
347
+ Microsoft Teams, any JSON webhook, or email. Any `--notify-*` flag turns on
348
+ `--compare`, and with `--interval` every pass that changes something alerts:
349
+
350
+ ```bash
351
+ ipmg --input targets.txt --interval 15 --notify-slack # URL from $IPMG_NOTIFY_SLACK
352
+ ipmg --input targets.txt --notify-email ops@example.com --smtp-host smtp.example.com
353
+ ipmg diff --notify-webhook https://ops.example.com/ipmg --notify-severity critical
354
+ ```
355
+
356
+ Only changes at or above `--notify-severity` (default `warning`) trigger a
357
+ notification. A notification that fails is reported but never fails the scan.
358
+ Secrets such as webhook URLs and `IPMG_SMTP_PASSWORD` can come from environment
359
+ variables; see [Notifications](docs/COMMANDS.md#notifications) for all of them.
360
+
346
361
  What counts as a change:
347
362
 
348
363
  | Change | Severity | Meaning |
@@ -375,6 +390,11 @@ source**, so file-based and `--discover` runs do not get mixed up. Pass
375
390
  | `--latency-threshold` | `5` | Minimum latency delta in ms |
376
391
  | `--latency-pct` | `25` | Minimum relative latency change |
377
392
  | `--fail-on-change` | off | `ipmg diff` exits 2 when changes are found |
393
+ | `--notify-webhook` | off | POST the change report as JSON to a URL |
394
+ | `--notify-slack` | off | Post changes to a Slack incoming webhook |
395
+ | `--notify-teams` | off | Post changes to a Microsoft Teams Workflows webhook |
396
+ | `--notify-email` | off | Email the Markdown change report (with `--smtp-host`, `--smtp-port`, `--smtp-security`, `--smtp-user`, `--smtp-from`) |
397
+ | `--notify-severity` | `warning` | Only notify for changes at least this severe |
378
398
 
379
399
  ---
380
400
 
@@ -415,6 +435,14 @@ package, nothing is loaded from a CDN. It gives you:
415
435
  | `--no-browser` | off | Don't open the browser automatically |
416
436
  | `--db` | `~/.ipmg/dashboard.db` | History database location |
417
437
 
438
+ ### Scripting IPMG Web
439
+
440
+ Everything the dashboard does goes through a documented, versioned REST API
441
+ under `/api/v1`, so you can start scans, fetch results, and compare scans from
442
+ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)
443
+ covers authentication, errors, and worked `curl` examples; the running server
444
+ also serves interactive docs at `http://127.0.0.1:8080/docs`.
445
+
418
446
  ### On a server with no browser
419
447
 
420
448
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -495,6 +523,21 @@ default). A finished scan overwrites those files with the complete report, so
495
523
  the file names and contents are the same as they always were. Use
496
524
  `--no-incremental` to go back to writing only at the end.
497
525
 
526
+ **Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
527
+ partial report already holds, scans only the rest, and finishes that same
528
+ report — same file names, same batch timestamp:
529
+
530
+ ```bash
531
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
532
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
533
+ ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
534
+ ```
535
+
536
+ Without a path, `--resume` takes the newest report named after `--output`,
537
+ preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
538
+ (current to the last autosave). Hosts dropped from the target list since are
539
+ left out of the finished report.
540
+
498
541
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
499
542
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
500
543
  HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
@@ -525,6 +568,7 @@ them — so piping IPMG into a file or a log gives you clean text.
525
568
  | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
526
569
  | `--no-incremental` | off | Only write the report once the scan has finished |
527
570
  | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
571
+ | `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
528
572
 
529
573
  **Speed and accuracy**
530
574
 
@@ -554,12 +598,21 @@ them — so piping IPMG into a file or a log gives you clean text.
554
598
  | `--stream-refresh` | `0.25` | Seconds between progress-bar redraws while streaming (0.05-5) |
555
599
  | `--verbose` | off | Debug logging |
556
600
 
601
+ **Exit status**
602
+
603
+ | Flag | Default | Description |
604
+ | --- | --- | --- |
605
+ | `--fail-on-down` | off | Exit 3 if any target is not `Active` (reports are still written) |
606
+ | `--min-active` | off | Exit 3 if fewer than this percentage of targets are `Active` |
607
+
557
608
  **History and changes** — see [Change detection](#change-detection) for
558
609
  `--compare`, `--no-history`, `--db`, `--diff-formats`, `--diff-output`,
559
- `--latency-threshold`, `--latency-pct`, and `--fail-on-change`.
610
+ `--latency-threshold`, `--latency-pct`, `--fail-on-change`, and the
611
+ `--notify-*` flags.
560
612
 
561
613
  Exit codes: `0` success, `1` error, `2` changes detected
562
- (`ipmg diff --fail-on-change`), `130` interrupted.
614
+ (`ipmg diff --fail-on-change`), `3` hosts down (`--fail-on-down`,
615
+ `--min-active`), `130` interrupted.
563
616
 
564
617
  ---
565
618
 
@@ -580,12 +633,15 @@ itself is hardened accordingly:
580
633
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
581
634
  so a bad input file cannot exhaust memory
582
635
  - All database access uses parameterized SQL
636
+ - The only outbound requests IPMG makes are the notifications you ask for
637
+ with a `--notify-*` flag. Their URLs and the SMTP password can come from
638
+ environment variables, and error messages never repeat them
583
639
 
584
640
  If you bind to a non-local address with `--host`, the token still guards the
585
641
  API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
586
642
  proxy with TLS on any network you don't trust.
587
643
 
588
- Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
644
+ Found a vulnerability? See [SECURITY.md](.github/SECURITY.md) for how to report it.
589
645
 
590
646
  ---
591
647
 
@@ -594,6 +650,9 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
594
650
  - **[Command reference](https://github.com/sameeralam3127/ipmg/blob/main/docs/COMMANDS.md)** —
595
651
  every command and flag with copy-paste examples, a safe session that tries
596
652
  everything on your own machine, exit codes, and error messages
653
+ - **[Web API guide](https://github.com/sameeralam3127/ipmg/blob/main/docs/API.md)** —
654
+ script IPMG Web over HTTP: start scans, fetch results, compare scans,
655
+ and follow live events
597
656
  - **[Troubleshooting](https://github.com/sameeralam3127/ipmg/blob/main/docs/TROUBLESHOOTING.md)** —
598
657
  install errors, `command not found`, every host timing out, slow scans,
599
658
  rejected input files
@@ -607,11 +666,11 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
607
666
  ## Contributing
608
667
 
609
668
  Contributions are welcome.
610
- [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/CONTRIBUTING.md)
669
+ [CONTRIBUTING.md](https://github.com/sameeralam3127/ipmg/blob/main/.github/CONTRIBUTING.md)
611
670
  covers setting up a development environment, running the tests, the commit
612
671
  message format that drives automated releases, and how the project website and
613
672
  IPMG Web demo are built. Everyone taking part is expected to follow the
614
- [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/CODE_OF_CONDUCT.md).
673
+ [Code of Conduct](https://github.com/sameeralam3127/ipmg/blob/main/.github/CODE_OF_CONDUCT.md).
615
674
 
616
675
  ---
617
676
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "2.1.1" # Managed automatically by semantic-release
16
+ version = "2.3.0" # Managed automatically by semantic-release
17
17
  description = "IP Management & Ping Monitoring CLI Tool"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.9"
@@ -53,6 +53,7 @@ dependencies = [
53
53
  "openpyxl>=3.1",
54
54
  "rich>=13.0",
55
55
  "fastapi>=0.110",
56
+ "pydantic>=2.7",
56
57
  "uvicorn>=0.27",
57
58
  "websockets>=12",
58
59
  "python-multipart>=0.0.9",
@@ -109,6 +110,9 @@ commit_message = "chore(release): {version}"
109
110
  build_command = "python -m build"
110
111
  major_on_zero = false
111
112
 
113
+ [tool.semantic_release.changelog.default_templates]
114
+ changelog_file = ".github/CHANGELOG.md"
115
+
112
116
 
113
117
  # ==========================================================
114
118
  # Setuptools (src layout)
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.1.1"
5
+ __version__ = "2.3.0"
@@ -13,8 +13,10 @@ from ipmg.cli.parser import (
13
13
  build_web_parser,
14
14
  )
15
15
  from ipmg.core.diff import DiffOptions
16
+ from ipmg.core.health import HostsDownError
16
17
  from ipmg.core.security import print_disclaimer_once
17
18
  from ipmg.exceptions import IPMGError
19
+ from ipmg.infrastructure.notify import notify_options, send_notifications
18
20
  from ipmg.reporting import ui
19
21
  from ipmg.reporting.diff_report import export_diff, print_diff
20
22
  from ipmg.reporting.summary import print_scan_history
@@ -25,6 +27,7 @@ from ipmg.utils.helpers import configure_logging
25
27
  EXIT_OK = 0
26
28
  EXIT_ERROR = 1
27
29
  EXIT_CHANGES_DETECTED = 2
30
+ EXIT_HOSTS_DOWN = 3
28
31
  EXIT_INTERRUPTED = 130
29
32
 
30
33
  log = logging.getLogger(__name__)
@@ -77,6 +80,7 @@ def _diff_command(argv: List[str]) -> int:
77
80
  ui.error("Provide at most two scan ids: BASELINE TARGET.")
78
81
  return EXIT_ERROR
79
82
 
83
+ notify = notify_options(args)
80
84
  history = HistoryService.open(args.db)
81
85
  options = DiffOptions(
82
86
  latency_abs_ms=max(args.latency_threshold, 0.0),
@@ -93,6 +97,7 @@ def _diff_command(argv: List[str]) -> int:
93
97
  print_diff(diff, limit=args.limit)
94
98
  if args.diff_formats:
95
99
  export_diff(diff, args.diff_output, args.diff_formats)
100
+ send_notifications(diff, notify)
96
101
 
97
102
  if args.fail_on_change and diff.has_changes:
98
103
  return EXIT_CHANGES_DETECTED
@@ -134,6 +139,12 @@ def run(argv: Optional[List[str]] = None) -> int:
134
139
 
135
140
  try:
136
141
  return handler(handler_argv)
142
+ except HostsDownError as exc:
143
+ # Not an error: the scan finished and wrote its reports, but failed
144
+ # the check it was asked to make.
145
+ ui.blank()
146
+ ui.warn(str(exc))
147
+ return EXIT_HOSTS_DOWN
137
148
  except IPMGError as exc:
138
149
  ui.blank()
139
150
  ui.error(str(exc))
@@ -13,6 +13,16 @@ from ipmg.infrastructure.incremental import (
13
13
  MIN_AUTOSAVE_S,
14
14
  REPORT_FORMATS,
15
15
  )
16
+ from ipmg.infrastructure.notify import (
17
+ DEFAULT_NOTIFY_SEVERITY,
18
+ ENV_EMAIL,
19
+ ENV_SLACK,
20
+ ENV_SMTP_PASSWORD,
21
+ ENV_TEAMS,
22
+ ENV_WEBHOOK,
23
+ NOTIFY_SEVERITIES,
24
+ SMTP_SECURITY,
25
+ )
16
26
  from ipmg.reporting.diff_report import DIFF_FORMATS
17
27
  from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
18
28
 
@@ -43,6 +53,16 @@ def _port_list(value: str) -> tuple:
43
53
  raise argparse.ArgumentTypeError(str(exc)) from exc
44
54
 
45
55
 
56
+ def _percent(value: str) -> float:
57
+ try:
58
+ percent = float(value)
59
+ except ValueError:
60
+ raise argparse.ArgumentTypeError(f"invalid percentage: {value!r}") from None
61
+ if not 0 <= percent <= 100:
62
+ raise argparse.ArgumentTypeError(f"percentage out of range (0-100): {value}")
63
+ return percent
64
+
65
+
46
66
  def _add_database_argument(parser) -> None:
47
67
  parser.add_argument(
48
68
  "--db",
@@ -87,6 +107,84 @@ def _add_diff_export_arguments(parser) -> None:
87
107
  )
88
108
 
89
109
 
110
+ def _add_notify_arguments(parser: argparse.ArgumentParser) -> None:
111
+ group = parser.add_argument_group(
112
+ "change notifications",
113
+ "Send the change report when a comparison finds changes. Each URL flag may be "
114
+ "given without a value to read it from its environment variable, which keeps "
115
+ "webhook secrets out of the process list and your crontab.",
116
+ )
117
+ group.add_argument(
118
+ "--notify-webhook",
119
+ nargs="?",
120
+ const="",
121
+ default=None,
122
+ metavar="URL",
123
+ help=f"POST the change report as JSON to URL (or ${ENV_WEBHOOK}).",
124
+ )
125
+ group.add_argument(
126
+ "--notify-slack",
127
+ nargs="?",
128
+ const="",
129
+ default=None,
130
+ metavar="URL",
131
+ help=f"Post the changes to a Slack incoming webhook (or ${ENV_SLACK}).",
132
+ )
133
+ group.add_argument(
134
+ "--notify-teams",
135
+ nargs="?",
136
+ const="",
137
+ default=None,
138
+ metavar="URL",
139
+ help=f"Post the changes to a Microsoft Teams Workflows webhook (or ${ENV_TEAMS}).",
140
+ )
141
+ group.add_argument(
142
+ "--notify-email",
143
+ nargs="*",
144
+ default=None,
145
+ metavar="ADDRESS",
146
+ help=(
147
+ "Email the Markdown change report to these addresses "
148
+ f"(or the comma-separated ${ENV_EMAIL}). Needs --smtp-host."
149
+ ),
150
+ )
151
+ group.add_argument(
152
+ "--notify-severity",
153
+ choices=NOTIFY_SEVERITIES,
154
+ default=DEFAULT_NOTIFY_SEVERITY,
155
+ help=(
156
+ "Only notify when a change is at least this severe "
157
+ f"(default: {DEFAULT_NOTIFY_SEVERITY})."
158
+ ),
159
+ )
160
+ group.add_argument(
161
+ "--smtp-host",
162
+ metavar="HOST",
163
+ help="Mail server for --notify-email (or $IPMG_SMTP_HOST).",
164
+ )
165
+ group.add_argument(
166
+ "--smtp-port",
167
+ type=int,
168
+ metavar="PORT",
169
+ help="Mail server port (or $IPMG_SMTP_PORT; default: 587, 465 with ssl, 25 with none).",
170
+ )
171
+ group.add_argument(
172
+ "--smtp-security",
173
+ choices=SMTP_SECURITY,
174
+ help="How to encrypt the mail connection (or $IPMG_SMTP_SECURITY; default: starttls).",
175
+ )
176
+ group.add_argument(
177
+ "--smtp-user",
178
+ metavar="USER",
179
+ help=f"Mail server login (or $IPMG_SMTP_USER). The password is read from ${ENV_SMTP_PASSWORD}.",
180
+ )
181
+ group.add_argument(
182
+ "--smtp-from",
183
+ metavar="ADDRESS",
184
+ help="Sender address (or $IPMG_SMTP_FROM; default: the login, else ipmg@<this host>).",
185
+ )
186
+
187
+
90
188
  def build_parser() -> argparse.ArgumentParser:
91
189
  parser = ScanParser(
92
190
  prog="ipmg",
@@ -178,6 +276,20 @@ def build_parser() -> argparse.ArgumentParser:
178
276
  help="Start IPMG Web, the local browser UI (same as 'ipmg web').",
179
277
  )
180
278
 
279
+ checks = parser.add_argument_group("exit status (for cron and monitoring)")
280
+ checks.add_argument(
281
+ "--fail-on-down",
282
+ action="store_true",
283
+ help="Exit with status 3 if any target is not Active. Reports are still written.",
284
+ )
285
+ checks.add_argument(
286
+ "--min-active",
287
+ type=_percent,
288
+ default=None,
289
+ metavar="PERCENT",
290
+ help="Exit with status 3 if fewer than PERCENT of the targets are Active (0-100).",
291
+ )
292
+
181
293
  ports_group = parser.add_argument_group("TCP service discovery")
182
294
  ports_group.add_argument(
183
295
  "--scan-ports",
@@ -219,6 +331,18 @@ def build_parser() -> argparse.ArgumentParser:
219
331
  "csv and jsonl are written per host regardless."
220
332
  ),
221
333
  )
334
+ reports.add_argument(
335
+ "--resume",
336
+ nargs="?",
337
+ const="",
338
+ default=None,
339
+ metavar="REPORT",
340
+ help=(
341
+ "Finish an interrupted scan: skip the hosts its report already holds and "
342
+ "complete that report. REPORT is its jsonl, csv, json, or xlsx file "
343
+ "(default: the newest report named after --output)."
344
+ ),
345
+ )
222
346
 
223
347
  live = parser.add_argument_group("live output")
224
348
  live.add_argument(
@@ -252,7 +376,10 @@ def build_parser() -> argparse.ArgumentParser:
252
376
  history.add_argument(
253
377
  "--compare",
254
378
  action="store_true",
255
- help="Compare this scan against the previous one and print a change report.",
379
+ help=(
380
+ "Compare this scan against the previous one and print a change report "
381
+ "(implied by any --notify-* flag)."
382
+ ),
256
383
  )
257
384
  history.add_argument(
258
385
  "--compare-any-source",
@@ -262,6 +389,7 @@ def build_parser() -> argparse.ArgumentParser:
262
389
  _add_database_argument(history)
263
390
  _add_diff_export_arguments(history)
264
391
  _add_diff_threshold_arguments(parser)
392
+ _add_notify_arguments(parser)
265
393
  parser.set_defaults(history=True)
266
394
  return parser
267
395
 
@@ -351,5 +479,6 @@ def build_diff_parser() -> argparse.ArgumentParser:
351
479
  _add_database_argument(parser)
352
480
  _add_diff_export_arguments(parser)
353
481
  _add_diff_threshold_arguments(parser)
482
+ _add_notify_arguments(parser)
354
483
  parser.add_argument("--verbose", action="store_true")
355
484
  return parser
@@ -48,6 +48,12 @@ _SEVERITY_BY_TYPE: Dict[ChangeType, Severity] = {
48
48
 
49
49
  _SEVERITY_RANK = {Severity.CRITICAL: 0, Severity.WARNING: 1, Severity.INFO: 2}
50
50
 
51
+
52
+ def meets_severity(severity: Severity, minimum: Severity) -> bool:
53
+ """Whether ``severity`` is at least as serious as ``minimum``."""
54
+ return _SEVERITY_RANK[severity] <= _SEVERITY_RANK[minimum]
55
+
56
+
51
57
  _TYPE_RANK = {change_type: index for index, change_type in enumerate(ChangeType)}
52
58
 
53
59
  CHANGE_LABELS: Dict[ChangeType, str] = {
@@ -0,0 +1,64 @@
1
+ """Pass/fail checks on a finished scan, for cron jobs and monitoring probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Sequence
7
+
8
+ from ipmg.core.diff import ip_sort_key
9
+ from ipmg.core.engine import HostResult
10
+ from ipmg.exceptions import IPMGError
11
+
12
+ ACTIVE_STATUS = "Active"
13
+
14
+ #: How many down hosts a failure message names before it summarises the rest.
15
+ MAX_NAMED_HOSTS = 5
16
+
17
+
18
+ class HostsDownError(IPMGError):
19
+ """Raised after a finished scan fails --fail-on-down or --min-active."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class HealthPolicy:
24
+ """When a scan counts as failed. The default policy never fails."""
25
+
26
+ #: Fail if any target is not ``Active``.
27
+ fail_on_down: bool = False
28
+ #: Fail if fewer than this percentage of targets are ``Active``.
29
+ min_active_pct: Optional[float] = None
30
+
31
+ @property
32
+ def enabled(self) -> bool:
33
+ return self.fail_on_down or self.min_active_pct is not None
34
+
35
+
36
+ def _name_hosts(ips: Sequence[str]) -> str:
37
+ # Results arrive in completion order; the message should read like the target list.
38
+ ips = sorted(ips, key=ip_sort_key)
39
+ named = ", ".join(ips[:MAX_NAMED_HOSTS])
40
+ hidden = len(ips) - MAX_NAMED_HOSTS
41
+ return f"{named} and {hidden} more" if hidden > 0 else named
42
+
43
+
44
+ def _format_pct(value: float) -> str:
45
+ return f"{value:.1f}".rstrip("0").rstrip(".") + "%"
46
+
47
+
48
+ def check_health(results: Sequence[HostResult], policy: HealthPolicy) -> Optional[str]:
49
+ """Why ``results`` fail ``policy``, or None when they pass."""
50
+ if not policy.enabled:
51
+ return None
52
+
53
+ total = len(results)
54
+ down = [result.ip for result in results if result.status != ACTIVE_STATUS]
55
+ active_pct = (total - len(down)) / total * 100 if total else 0.0
56
+
57
+ if policy.min_active_pct is not None and active_pct < policy.min_active_pct:
58
+ return (
59
+ f"Only {_format_pct(active_pct)} of {total} hosts are active, "
60
+ f"below --min-active {_format_pct(policy.min_active_pct)}."
61
+ )
62
+ if policy.fail_on_down and down:
63
+ return f"{len(down)} of {total} hosts are not active: {_name_hosts(down)}."
64
+ return None
@@ -27,3 +27,7 @@ class ReportError(IPMGError):
27
27
 
28
28
  class HistoryError(IPMGError):
29
29
  """Raised when scan history cannot be stored, read, or compared."""
30
+
31
+
32
+ class NotifyError(IPMGError):
33
+ """Raised when change notifications are configured incorrectly."""