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.
- {ipmg-2.1.1/src/ipmg.egg-info → ipmg-2.3.0}/PKG-INFO +66 -6
- {ipmg-2.1.1 → ipmg-2.3.0}/README.md +64 -5
- {ipmg-2.1.1 → ipmg-2.3.0}/pyproject.toml +5 -1
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/__init__.py +1 -1
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/commands.py +11 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/parser.py +130 -1
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/diff.py +6 -0
- ipmg-2.3.0/src/ipmg/core/health.py +64 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/exceptions.py +4 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/incremental.py +213 -5
- ipmg-2.3.0/src/ipmg/infrastructure/notify.py +403 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/ui.py +2 -1
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/scan_service.py +88 -10
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/app.py +218 -39
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/manager.py +18 -16
- ipmg-2.3.0/src/ipmg/web/schemas.py +239 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/server.py +1 -1
- {ipmg-2.1.1 → ipmg-2.3.0/src/ipmg.egg-info}/PKG-INFO +66 -6
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/SOURCES.txt +6 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/requires.txt +1 -0
- ipmg-2.3.0/tests/test_api_contract.py +153 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_commands.py +50 -0
- ipmg-2.3.0/tests/test_health.py +63 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_incremental.py +163 -0
- ipmg-2.3.0/tests/test_notify.py +520 -0
- ipmg-2.3.0/tests/test_parser.py +140 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_scan_service.py +140 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_api.py +54 -0
- ipmg-2.1.1/tests/test_parser.py +0 -57
- {ipmg-2.1.1 → ipmg-2.3.0}/LICENSE +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/setup.cfg +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/__main__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/cli/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/discovery.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/engine.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/ping.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/portscan.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/core/security.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/database.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/infrastructure/file_io.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/diff_report.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/frames.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/live.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/reporting/summary.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/services/history_service.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/utils/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/utils/helpers.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/db.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/css/app.css +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/index.html +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/api.js +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/app.js +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/charts.js +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/demo.js +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg/web/static/js/views.js +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/entry_points.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/src/ipmg.egg-info/top_level.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_database_history.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_diff.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_diff_report.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_discover.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_engine.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_file_io.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_history_service.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_live.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ping.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ping_command.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_portscan.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_ui.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_utils.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_db.py +0 -0
- {ipmg-2.1.1 → ipmg-2.3.0}/tests/test_web_manager.py +0 -0
- {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.
|
|
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`,
|
|
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`), `
|
|
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`,
|
|
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`), `
|
|
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.
|
|
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)
|
|
@@ -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=
|
|
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
|