ipmg 2.0.0__tar.gz → 2.1.1__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 (72) hide show
  1. {ipmg-2.0.0/src/ipmg.egg-info → ipmg-2.1.1}/PKG-INFO +82 -15
  2. {ipmg-2.0.0 → ipmg-2.1.1}/README.md +78 -12
  3. {ipmg-2.0.0 → ipmg-2.1.1}/pyproject.toml +13 -3
  4. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/cli/parser.py +26 -2
  6. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/engine.py +53 -14
  7. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/portscan.py +19 -10
  8. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/infrastructure/file_io.py +45 -15
  9. ipmg-2.1.1/src/ipmg/infrastructure/incremental.py +276 -0
  10. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/services/scan_service.py +89 -6
  11. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/utils/helpers.py +5 -1
  12. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/app.py +75 -9
  13. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/manager.py +24 -2
  14. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/server.py +37 -5
  15. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/index.html +2 -2
  16. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/js/api.js +63 -2
  17. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/js/app.js +2 -2
  18. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/js/views.js +25 -8
  19. {ipmg-2.0.0 → ipmg-2.1.1/src/ipmg.egg-info}/PKG-INFO +82 -15
  20. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg.egg-info/SOURCES.txt +3 -0
  21. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg.egg-info/requires.txt +2 -1
  22. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_engine.py +54 -3
  23. ipmg-2.1.1/tests/test_incremental.py +227 -0
  24. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_scan_service.py +11 -11
  25. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_utils.py +12 -0
  26. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_web_api.py +95 -3
  27. ipmg-2.1.1/tests/test_web_manager.py +36 -0
  28. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_web_server.py +51 -0
  29. {ipmg-2.0.0 → ipmg-2.1.1}/LICENSE +0 -0
  30. {ipmg-2.0.0 → ipmg-2.1.1}/setup.cfg +0 -0
  31. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/__main__.py +0 -0
  32. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/cli/__init__.py +0 -0
  33. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/cli/commands.py +0 -0
  34. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/__init__.py +0 -0
  35. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/diff.py +0 -0
  36. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/discovery.py +0 -0
  37. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/ping.py +0 -0
  38. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/core/security.py +0 -0
  39. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/exceptions.py +0 -0
  40. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/infrastructure/__init__.py +0 -0
  41. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/infrastructure/database.py +0 -0
  42. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/__init__.py +0 -0
  43. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/diff_report.py +0 -0
  44. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/frames.py +0 -0
  45. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/live.py +0 -0
  46. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/summary.py +0 -0
  47. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/reporting/ui.py +0 -0
  48. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/services/__init__.py +0 -0
  49. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/services/history_service.py +0 -0
  50. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/utils/__init__.py +0 -0
  51. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/__init__.py +0 -0
  52. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/db.py +0 -0
  53. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/css/app.css +0 -0
  54. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/js/charts.js +0 -0
  55. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg/web/static/js/demo.js +0 -0
  56. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg.egg-info/dependency_links.txt +0 -0
  57. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg.egg-info/entry_points.txt +0 -0
  58. {ipmg-2.0.0 → ipmg-2.1.1}/src/ipmg.egg-info/top_level.txt +0 -0
  59. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_commands.py +0 -0
  60. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_database_history.py +0 -0
  61. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_diff.py +0 -0
  62. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_diff_report.py +0 -0
  63. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_discover.py +0 -0
  64. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_file_io.py +0 -0
  65. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_history_service.py +0 -0
  66. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_live.py +0 -0
  67. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_parser.py +0 -0
  68. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_ping.py +0 -0
  69. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_ping_command.py +0 -0
  70. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_portscan.py +0 -0
  71. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_ui.py +0 -0
  72. {ipmg-2.0.0 → ipmg-2.1.1}/tests/test_web_db.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -10,7 +10,7 @@ Project-URL: Documentation, https://github.com/sameeralam3127/ipmg#readme
10
10
  Project-URL: Repository, https://github.com/sameeralam3127/ipmg
11
11
  Project-URL: Issues, https://github.com/sameeralam3127/ipmg/issues
12
12
  Project-URL: Changelog, https://github.com/sameeralam3127/ipmg/releases
13
- Keywords: ip,network,ping,monitoring,cli,port-scan
13
+ Keywords: ip,network,ping,monitoring,cli,port-scan,network-scanner,ping-sweep,subnet-scanner,homelab,sysadmin
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Programming Language :: Python :: 3.9
@@ -29,7 +29,7 @@ Classifier: Environment :: Console
29
29
  Requires-Python: >=3.9
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
- Requires-Dist: pandas>=2.0
32
+ 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
@@ -38,6 +38,7 @@ Requires-Dist: websockets>=12
38
38
  Requires-Dist: python-multipart>=0.0.9
39
39
  Provides-Extra: dev
40
40
  Requires-Dist: pytest>=7.4; extra == "dev"
41
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
41
42
  Requires-Dist: ruff>=0.4; extra == "dev"
42
43
  Requires-Dist: pre-commit>=3.5.0; extra == "dev"
43
44
  Requires-Dist: python-semantic-release>=10.5.3; extra == "dev"
@@ -58,8 +59,19 @@ can send to someone: Excel, CSV, JSON, or Markdown. It works from the command
58
59
  line or from IPMG Web, a local browser UI, and it remembers every scan so it can tell
59
60
  you what moved.
60
61
 
62
+ <p align="center">
63
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-demo.gif" alt="ipmg scanning 13 hosts in parallel: live results with reverse DNS names and latency, then a summary of 10 active and 3 timed out" width="820">
64
+ </p>
65
+
66
+ **Why not `nmap -sn`, `fping`, or Angry IP Scanner?** They tell you what is up
67
+ right now. IPMG also remembers every scan and tells you what changed since the
68
+ last one: the host that dropped off, the device that appeared, the latency that
69
+ doubled. It writes the report you would otherwise build by hand.
70
+ [How it compares](#how-it-compares)
71
+
61
72
  **Website:** [sameeralam3127.github.io/ipmg](https://sameeralam3127.github.io/ipmg/) ·
62
- **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/)
73
+ **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/) ·
74
+ **Command builder:** [pick what you want, copy the command](https://sameeralam3127.github.io/ipmg/#builder)
63
75
 
64
76
  ```bash
65
77
  pip install ipmg
@@ -71,6 +83,7 @@ ipmg --discover # scan the network you are on, right now
71
83
 
72
84
  **Contents**
73
85
 
86
+ [How it compares](#how-it-compares) ·
74
87
  [Install](#install) ·
75
88
  [Your first scan](#your-first-scan) ·
76
89
  [Common tasks](#common-tasks) ·
@@ -86,6 +99,26 @@ ipmg --discover # scan the network you are on, right now
86
99
 
87
100
  ---
88
101
 
102
+ ## How it compares
103
+
104
+ | | IPMG | `nmap -sn` | `fping` | Angry IP Scanner |
105
+ | --- | --- | --- | --- | --- |
106
+ | Parallel ping sweep | Yes | Yes | Yes | Yes |
107
+ | Scan history and "what changed" | Built in | Save XML, compare with `ndiff` | No | No |
108
+ | Reports | Excel, CSV, JSON, Markdown | XML, grepable text | Plain text | CSV, TXT, XML |
109
+ | Browser UI | IPMG Web, local | No (Zenmap is a desktop app) | No | Desktop app (Java) |
110
+ | Port checks | Common TCP ports | Full port and OS scanner | No | Via fetchers |
111
+
112
+ Reach for nmap when you need a real port or OS scanner. Reach for IPMG when you
113
+ look after a network and need to know what moved since yesterday, with a report
114
+ you can hand to someone.
115
+
116
+ <p align="center">
117
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-web.png" alt="IPMG Web dashboard: scan totals, a status donut of 16 active, 1 timeout and 1 inactive host, a latency trend chart, and a list of recent scans" width="820">
118
+ </p>
119
+
120
+ ---
121
+
89
122
  ## Install
90
123
 
91
124
  **Linux and macOS — one command, works on every distribution:**
@@ -280,6 +313,19 @@ exist yet. A file you name with `--input` must already exist.
280
313
 
281
314
  ## Common tasks
282
315
 
316
+ Not sure which flags you need? The
317
+ [command builder](https://sameeralam3127.github.io/ipmg/#builder) on the
318
+ website puts the command together as you pick what you want to know, and
319
+ explains every flag it adds.
320
+
321
+ <p align="center">
322
+ <a href="https://sameeralam3127.github.io/ipmg/#builder">
323
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-builder.png" alt="IPMG command builder: choose Scan, Compare, History, or Web, enter a target such as 192.168.1.0/24, switch on options like live results or hostnames, and copy the generated ipmg command with each flag explained" width="820">
324
+ </a>
325
+ </p>
326
+
327
+ Or pick a ready-made command:
328
+
283
329
  | I want to… | Command |
284
330
  | --- | --- |
285
331
  | Scan the network I am on | `ipmg --discover` |
@@ -390,6 +436,12 @@ ipmg web # starts http://127.0.0.1:8080 and opens your browser
390
436
 
391
437
  `ipmg --web` does the same thing, so whichever one you reach for first works.
392
438
 
439
+ Each start creates a new access token. The browser opens with it already in
440
+ the link, and IPMG Web prints that link (`http://127.0.0.1:8080/#token=…`)
441
+ in the terminal. If you open IPMG Web in another browser, or after a
442
+ restart, use the link from the terminal. To keep the same token across
443
+ restarts, for example behind a reverse proxy, set `IPMG_WEB_TOKEN`.
444
+
393
445
  It runs fully offline — every stylesheet and script is bundled with the
394
446
  package, nothing is loaded from a CDN. It gives you:
395
447
 
@@ -419,12 +471,13 @@ it from your workstation with an SSH tunnel:
419
471
 
420
472
  ```bash
421
473
  ssh -L 8080:127.0.0.1:8080 user@server
422
- # then open http://127.0.0.1:8080 locally
474
+ # then open the link the server printed (http://127.0.0.1:8080/#token=…) locally
423
475
  ```
424
476
 
425
- Alternatively, bind to all interfaces with `--host 0.0.0.0` — this exposes an
426
- unauthenticated API on the network, so only do this on a trusted network or
427
- behind a reverse proxy with authentication (see [Security](#security)).
477
+ Alternatively, bind to all interfaces with `--host 0.0.0.0`. Every request
478
+ still needs the access token. The traffic, token included, is plain HTTP,
479
+ so on anything but a trusted network put a reverse proxy with TLS in front
480
+ of it (see [Security](#security)).
428
481
 
429
482
  ---
430
483
 
@@ -478,7 +531,16 @@ Status is one of `Active`, `Inactive`, `Timeout`, `Unreachable`, `Invalid IP`,
478
531
  or `Error`. `Hostname` is filled in when you pass `--resolve`.
479
532
 
480
533
  The `md` format produces a shareable Markdown report with a status summary
481
- table — handy for tickets, handoffs, and incident timelines.
534
+ table — handy for tickets, handoffs, and incident timelines. `jsonl` writes one
535
+ JSON object per line, which streams into `jq` and log pipelines.
536
+
537
+ **Reports survive an interrupted scan.** A scan writes its report as it goes,
538
+ so pressing Ctrl+C halfway through a /16 leaves a valid report of everything
539
+ scanned so far instead of nothing at all. `csv` and `jsonl` are appended per
540
+ host; `xlsx`, `json`, and `md` are re-saved every `--autosave` seconds (30 by
541
+ default). A finished scan overwrites those files with the complete report, so
542
+ the file names and contents are the same as they always were. Use
543
+ `--no-incremental` to go back to writing only at the end.
482
544
 
483
545
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
484
546
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
@@ -507,7 +569,9 @@ them — so piping IPMG into a file or a log gives you clean text.
507
569
  | `--input` | `ip_list.xlsx` | What to scan: a file (`.xlsx`, `.xls`, `.csv`, `.txt`, `.list`), a single IP, a CIDR block, or a range (`10.0.0.1-10.0.0.50`) |
508
570
  | `--discover` | off | Auto-detect and scan the local subnet instead |
509
571
  | `--output` | `results` | Report file name prefix |
510
- | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `md` |
572
+ | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
573
+ | `--no-incremental` | off | Only write the report once the scan has finished |
574
+ | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
511
575
 
512
576
  **Speed and accuracy**
513
577
 
@@ -555,15 +619,18 @@ itself is hardened accordingly:
555
619
  validated as an IP address first
556
620
  - IPMG Web binds to `127.0.0.1` by default and serves everything
557
621
  locally — no CDN assets, no outbound requests
558
- - WebSocket connections are origin-checked, so a web page you happen to
559
- visit cannot connect to your local IPMG Web and read your scan results
622
+ - Every API request and WebSocket needs the access token created when
623
+ IPMG Web starts, so other users on the machine and web pages you happen
624
+ to visit cannot start scans or read your results
625
+ - WebSocket connections are also origin-checked, and a client that stops
626
+ reading live updates is disconnected rather than buffered without limit
560
627
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
561
628
  so a bad input file cannot exhaust memory
562
629
  - All database access uses parameterized SQL
563
630
 
564
- If you bind to a non-local address with `--host`, anyone who can reach that
565
- interface can start scans and read results — put a reverse proxy with
566
- authentication in front of it.
631
+ If you bind to a non-local address with `--host`, the token still guards the
632
+ API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
633
+ proxy with TLS on any network you don't trust.
567
634
 
568
635
  Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
569
636
 
@@ -12,8 +12,19 @@ can send to someone: Excel, CSV, JSON, or Markdown. It works from the command
12
12
  line or from IPMG Web, a local browser UI, and it remembers every scan so it can tell
13
13
  you what moved.
14
14
 
15
+ <p align="center">
16
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-demo.gif" alt="ipmg scanning 13 hosts in parallel: live results with reverse DNS names and latency, then a summary of 10 active and 3 timed out" width="820">
17
+ </p>
18
+
19
+ **Why not `nmap -sn`, `fping`, or Angry IP Scanner?** They tell you what is up
20
+ right now. IPMG also remembers every scan and tells you what changed since the
21
+ last one: the host that dropped off, the device that appeared, the latency that
22
+ doubled. It writes the report you would otherwise build by hand.
23
+ [How it compares](#how-it-compares)
24
+
15
25
  **Website:** [sameeralam3127.github.io/ipmg](https://sameeralam3127.github.io/ipmg/) ·
16
- **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/)
26
+ **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/) ·
27
+ **Command builder:** [pick what you want, copy the command](https://sameeralam3127.github.io/ipmg/#builder)
17
28
 
18
29
  ```bash
19
30
  pip install ipmg
@@ -25,6 +36,7 @@ ipmg --discover # scan the network you are on, right now
25
36
 
26
37
  **Contents**
27
38
 
39
+ [How it compares](#how-it-compares) ·
28
40
  [Install](#install) ·
29
41
  [Your first scan](#your-first-scan) ·
30
42
  [Common tasks](#common-tasks) ·
@@ -40,6 +52,26 @@ ipmg --discover # scan the network you are on, right now
40
52
 
41
53
  ---
42
54
 
55
+ ## How it compares
56
+
57
+ | | IPMG | `nmap -sn` | `fping` | Angry IP Scanner |
58
+ | --- | --- | --- | --- | --- |
59
+ | Parallel ping sweep | Yes | Yes | Yes | Yes |
60
+ | Scan history and "what changed" | Built in | Save XML, compare with `ndiff` | No | No |
61
+ | Reports | Excel, CSV, JSON, Markdown | XML, grepable text | Plain text | CSV, TXT, XML |
62
+ | Browser UI | IPMG Web, local | No (Zenmap is a desktop app) | No | Desktop app (Java) |
63
+ | Port checks | Common TCP ports | Full port and OS scanner | No | Via fetchers |
64
+
65
+ Reach for nmap when you need a real port or OS scanner. Reach for IPMG when you
66
+ look after a network and need to know what moved since yesterday, with a report
67
+ you can hand to someone.
68
+
69
+ <p align="center">
70
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-web.png" alt="IPMG Web dashboard: scan totals, a status donut of 16 active, 1 timeout and 1 inactive host, a latency trend chart, and a list of recent scans" width="820">
71
+ </p>
72
+
73
+ ---
74
+
43
75
  ## Install
44
76
 
45
77
  **Linux and macOS — one command, works on every distribution:**
@@ -234,6 +266,19 @@ exist yet. A file you name with `--input` must already exist.
234
266
 
235
267
  ## Common tasks
236
268
 
269
+ Not sure which flags you need? The
270
+ [command builder](https://sameeralam3127.github.io/ipmg/#builder) on the
271
+ website puts the command together as you pick what you want to know, and
272
+ explains every flag it adds.
273
+
274
+ <p align="center">
275
+ <a href="https://sameeralam3127.github.io/ipmg/#builder">
276
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-builder.png" alt="IPMG command builder: choose Scan, Compare, History, or Web, enter a target such as 192.168.1.0/24, switch on options like live results or hostnames, and copy the generated ipmg command with each flag explained" width="820">
277
+ </a>
278
+ </p>
279
+
280
+ Or pick a ready-made command:
281
+
237
282
  | I want to… | Command |
238
283
  | --- | --- |
239
284
  | Scan the network I am on | `ipmg --discover` |
@@ -344,6 +389,12 @@ ipmg web # starts http://127.0.0.1:8080 and opens your browser
344
389
 
345
390
  `ipmg --web` does the same thing, so whichever one you reach for first works.
346
391
 
392
+ Each start creates a new access token. The browser opens with it already in
393
+ the link, and IPMG Web prints that link (`http://127.0.0.1:8080/#token=…`)
394
+ in the terminal. If you open IPMG Web in another browser, or after a
395
+ restart, use the link from the terminal. To keep the same token across
396
+ restarts, for example behind a reverse proxy, set `IPMG_WEB_TOKEN`.
397
+
347
398
  It runs fully offline — every stylesheet and script is bundled with the
348
399
  package, nothing is loaded from a CDN. It gives you:
349
400
 
@@ -373,12 +424,13 @@ it from your workstation with an SSH tunnel:
373
424
 
374
425
  ```bash
375
426
  ssh -L 8080:127.0.0.1:8080 user@server
376
- # then open http://127.0.0.1:8080 locally
427
+ # then open the link the server printed (http://127.0.0.1:8080/#token=…) locally
377
428
  ```
378
429
 
379
- Alternatively, bind to all interfaces with `--host 0.0.0.0` — this exposes an
380
- unauthenticated API on the network, so only do this on a trusted network or
381
- behind a reverse proxy with authentication (see [Security](#security)).
430
+ Alternatively, bind to all interfaces with `--host 0.0.0.0`. Every request
431
+ still needs the access token. The traffic, token included, is plain HTTP,
432
+ so on anything but a trusted network put a reverse proxy with TLS in front
433
+ of it (see [Security](#security)).
382
434
 
383
435
  ---
384
436
 
@@ -432,7 +484,16 @@ Status is one of `Active`, `Inactive`, `Timeout`, `Unreachable`, `Invalid IP`,
432
484
  or `Error`. `Hostname` is filled in when you pass `--resolve`.
433
485
 
434
486
  The `md` format produces a shareable Markdown report with a status summary
435
- table — handy for tickets, handoffs, and incident timelines.
487
+ table — handy for tickets, handoffs, and incident timelines. `jsonl` writes one
488
+ JSON object per line, which streams into `jq` and log pipelines.
489
+
490
+ **Reports survive an interrupted scan.** A scan writes its report as it goes,
491
+ so pressing Ctrl+C halfway through a /16 leaves a valid report of everything
492
+ scanned so far instead of nothing at all. `csv` and `jsonl` are appended per
493
+ host; `xlsx`, `json`, and `md` are re-saved every `--autosave` seconds (30 by
494
+ default). A finished scan overwrites those files with the complete report, so
495
+ the file names and contents are the same as they always were. Use
496
+ `--no-incremental` to go back to writing only at the end.
436
497
 
437
498
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
438
499
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
@@ -461,7 +522,9 @@ them — so piping IPMG into a file or a log gives you clean text.
461
522
  | `--input` | `ip_list.xlsx` | What to scan: a file (`.xlsx`, `.xls`, `.csv`, `.txt`, `.list`), a single IP, a CIDR block, or a range (`10.0.0.1-10.0.0.50`) |
462
523
  | `--discover` | off | Auto-detect and scan the local subnet instead |
463
524
  | `--output` | `results` | Report file name prefix |
464
- | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `md` |
525
+ | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
526
+ | `--no-incremental` | off | Only write the report once the scan has finished |
527
+ | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
465
528
 
466
529
  **Speed and accuracy**
467
530
 
@@ -509,15 +572,18 @@ itself is hardened accordingly:
509
572
  validated as an IP address first
510
573
  - IPMG Web binds to `127.0.0.1` by default and serves everything
511
574
  locally — no CDN assets, no outbound requests
512
- - WebSocket connections are origin-checked, so a web page you happen to
513
- visit cannot connect to your local IPMG Web and read your scan results
575
+ - Every API request and WebSocket needs the access token created when
576
+ IPMG Web starts, so other users on the machine and web pages you happen
577
+ to visit cannot start scans or read your results
578
+ - WebSocket connections are also origin-checked, and a client that stops
579
+ reading live updates is disconnected rather than buffered without limit
514
580
  - Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
515
581
  so a bad input file cannot exhaust memory
516
582
  - All database access uses parameterized SQL
517
583
 
518
- If you bind to a non-local address with `--host`, anyone who can reach that
519
- interface can start scans and read results — put a reverse proxy with
520
- authentication in front of it.
584
+ If you bind to a non-local address with `--host`, the token still guards the
585
+ API. It travels over plain HTTP, though, so use an SSH tunnel or a reverse
586
+ proxy with TLS on any network you don't trust.
521
587
 
522
588
  Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
523
589
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "2.0.0" # Managed automatically by semantic-release
16
+ version = "2.1.1" # 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"
@@ -28,7 +28,7 @@ maintainers = [
28
28
  { name = "Sameer Alam", email = "sameeralam3127@gmail.com" }
29
29
  ]
30
30
 
31
- keywords = ["ip", "network", "ping", "monitoring", "cli", "port-scan"]
31
+ keywords = ["ip", "network", "ping", "monitoring", "cli", "port-scan", "network-scanner", "ping-sweep", "subnet-scanner", "homelab", "sysadmin"]
32
32
 
33
33
  classifiers = [
34
34
  "Programming Language :: Python :: 3",
@@ -49,7 +49,7 @@ classifiers = [
49
49
  ]
50
50
 
51
51
  dependencies = [
52
- "pandas>=2.0",
52
+ "pandas>=2.2.2",
53
53
  "openpyxl>=3.1",
54
54
  "rich>=13.0",
55
55
  "fastapi>=0.110",
@@ -68,6 +68,7 @@ Changelog = "https://github.com/sameeralam3127/ipmg/releases"
68
68
  [project.optional-dependencies]
69
69
  dev = [
70
70
  "pytest>=7.4",
71
+ "pytest-cov>=5.0",
71
72
  "ruff>=0.4",
72
73
  "pre-commit>=3.5.0",
73
74
  "python-semantic-release>=10.5.3",
@@ -85,6 +86,7 @@ ipmg = "ipmg.cli.commands:run"
85
86
  [dependency-groups]
86
87
  dev = [
87
88
  "pytest>=7.4",
89
+ "pytest-cov>=5.0",
88
90
  "ruff>=0.4",
89
91
  "pre-commit>=3.5.0",
90
92
  "python-semantic-release>=10.5.3",
@@ -130,6 +132,14 @@ where = ["src"]
130
132
  testpaths = ["tests"]
131
133
  pythonpath = ["src"]
132
134
 
135
+ [tool.coverage.run]
136
+ source = ["ipmg"]
137
+ branch = true
138
+
139
+ [tool.coverage.report]
140
+ # Current coverage is ~95%; this floor catches a large untested addition.
141
+ fail_under = 90
142
+
133
143
 
134
144
  # ==========================================================
135
145
  # Ruff
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.0.0"
5
+ __version__ = "2.1.1"
@@ -7,6 +7,12 @@ import argparse
7
7
  from ipmg import __version__
8
8
  from ipmg.core.portscan import DEFAULT_PORTS, parse_port_list
9
9
  from ipmg.infrastructure.file_io import DEFAULT_INPUT_FILE
10
+ from ipmg.infrastructure.incremental import (
11
+ DEFAULT_AUTOSAVE_S,
12
+ MAX_AUTOSAVE_S,
13
+ MIN_AUTOSAVE_S,
14
+ REPORT_FORMATS,
15
+ )
10
16
  from ipmg.reporting.diff_report import DIFF_FORMATS
11
17
  from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
12
18
 
@@ -135,9 +141,9 @@ def build_parser() -> argparse.ArgumentParser:
135
141
  "--formats",
136
142
  nargs="+",
137
143
  default=["xlsx"],
138
- choices=["xlsx", "csv", "json", "md"],
144
+ choices=list(REPORT_FORMATS),
139
145
  metavar="FORMAT",
140
- help="One or more report formats: xlsx, csv, json, md (default: xlsx).",
146
+ help=f"One or more report formats: {', '.join(REPORT_FORMATS)} (default: xlsx).",
141
147
  )
142
148
  parser.add_argument(
143
149
  "--discover",
@@ -196,6 +202,24 @@ def build_parser() -> argparse.ArgumentParser:
196
202
  help="Connect timeout per port when --scan-ports is set (default: 1).",
197
203
  )
198
204
 
205
+ reports = parser.add_argument_group("report writing")
206
+ reports.add_argument(
207
+ "--no-incremental",
208
+ action="store_true",
209
+ help="Only write the report once the scan has finished.",
210
+ )
211
+ reports.add_argument(
212
+ "--autosave",
213
+ type=float,
214
+ default=DEFAULT_AUTOSAVE_S,
215
+ metavar="SECONDS",
216
+ help=(
217
+ "How often a running scan re-saves the xlsx, json, and md reports "
218
+ f"(default: {DEFAULT_AUTOSAVE_S:g}, range: {MIN_AUTOSAVE_S:g}-{MAX_AUTOSAVE_S:g}). "
219
+ "csv and jsonl are written per host regardless."
220
+ ),
221
+ )
222
+
199
223
  live = parser.add_argument_group("live output")
200
224
  live.add_argument(
201
225
  "--stream",
@@ -11,6 +11,11 @@ from ipmg.core.portscan import DEFAULT_PORTS, scan_ports
11
11
  from ipmg.exceptions import PingError
12
12
  from ipmg.utils.helpers import HostnameCache, clamp_int
13
13
 
14
+ #: Upper bound on TCP connect probes in flight across a whole scan. Every host
15
+ #: worker shares this one pool, so --scan-ports cannot create
16
+ #: threads x ports probe threads.
17
+ MAX_PORT_PROBE_WORKERS = 128
18
+
14
19
 
15
20
  @dataclass(frozen=True)
16
21
  class ScanConfig:
@@ -65,37 +70,71 @@ def execute_scan(
65
70
  cache = HostnameCache(config.dns_cache_ttl) if config.resolve else None
66
71
  results: List[HostResult] = []
67
72
 
73
+ port_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
74
+ if config.scan_ports and config.ports:
75
+ port_executor = concurrent.futures.ThreadPoolExecutor(
76
+ max_workers=min(MAX_PORT_PROBE_WORKERS, config.threads * len(config.ports)),
77
+ thread_name_prefix="ipmg-port",
78
+ )
79
+
68
80
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=config.threads)
69
81
  try:
70
- futures = {executor.submit(ping_ip, ip, config.timeout, config.count): ip for ip in ips}
82
+ futures = [executor.submit(_probe_host, ip, config, cache, port_executor) for ip in ips]
71
83
 
72
84
  for future in concurrent.futures.as_completed(futures):
73
85
  if should_stop is not None and should_stop():
74
86
  executor.shutdown(wait=False, cancel_futures=True)
75
87
  break
76
88
 
77
- ip = futures[future]
78
89
  try:
79
- status, latency = future.result()
90
+ result = future.result()
80
91
  except PingError:
81
92
  executor.shutdown(wait=False, cancel_futures=True)
82
93
  raise
83
- except Exception:
84
- status, latency = "Error", None
85
-
86
- hostname = cache.resolve(ip) if cache else ""
87
- open_ports: Tuple[int, ...] = ()
88
- if config.scan_ports and status == "Active":
89
- open_ports = tuple(scan_ports(ip, config.ports, config.port_timeout))
90
-
91
- result = HostResult(
92
- ip=ip, status=status, latency=latency, hostname=hostname, open_ports=open_ports
93
- )
94
94
  results.append(result)
95
95
 
96
96
  if on_result is not None:
97
97
  on_result(result, len(results), len(ips))
98
+ except BaseException:
99
+ # Ctrl+C must not be followed by minutes of queued pings: drop the
100
+ # hosts that have not started, and let the finally below wait only
101
+ # for the handful already in flight.
102
+ executor.shutdown(wait=False, cancel_futures=True)
103
+ raise
98
104
  finally:
99
105
  executor.shutdown(wait=True)
106
+ if port_executor is not None:
107
+ port_executor.shutdown(wait=True)
100
108
 
101
109
  return results
110
+
111
+
112
+ def _probe_host(
113
+ ip: str,
114
+ config: ScanConfig,
115
+ cache: Optional[HostnameCache],
116
+ port_executor: Optional[concurrent.futures.Executor],
117
+ ) -> HostResult:
118
+ """Ping one host, then resolve its name and probe its ports.
119
+
120
+ Runs on a pool worker, so reverse DNS is spread across ``config.threads``
121
+ workers instead of running one host at a time on the thread that collects
122
+ results. Port probes go to the scan's shared, bounded ``port_executor``.
123
+ """
124
+ try:
125
+ status, latency = ping_ip(ip, config.timeout, config.count)
126
+ except PingError:
127
+ raise
128
+ except Exception:
129
+ status, latency = "Error", None
130
+
131
+ hostname = cache.resolve(ip) if cache else ""
132
+ open_ports: Tuple[int, ...] = ()
133
+ if config.scan_ports and status == "Active":
134
+ open_ports = tuple(
135
+ scan_ports(ip, config.ports, config.port_timeout, executor=port_executor)
136
+ )
137
+
138
+ return HostResult(
139
+ ip=ip, status=status, latency=latency, hostname=hostname, open_ports=open_ports
140
+ )
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import concurrent.futures
6
6
  import socket
7
- from typing import Iterable, List, Tuple
7
+ from typing import Iterable, List, Optional, Tuple
8
8
 
9
9
  #: Common services worth a quick TCP connect once a host is known to be up.
10
10
  DEFAULT_PORTS: Tuple[int, ...] = (21, 22, 25, 53, 80, 443, 445, 1433, 3306, 3389, 5432)
@@ -65,18 +65,27 @@ def parse_port_list(value: str) -> Tuple[int, ...]:
65
65
  return tuple(ports)
66
66
 
67
67
 
68
- def scan_ports(ip: str, ports: Iterable[int], timeout: float = 1.0) -> List[int]:
69
- """Probe ``ports`` on ``ip`` concurrently; return the ones that accepted a connection."""
68
+ def scan_ports(
69
+ ip: str,
70
+ ports: Iterable[int],
71
+ timeout: float = 1.0,
72
+ executor: Optional[concurrent.futures.Executor] = None,
73
+ ) -> List[int]:
74
+ """Probe ``ports`` on ``ip`` concurrently; return the ones that accepted a connection.
75
+
76
+ Pass ``executor`` to share one bounded pool across many hosts; otherwise a
77
+ pool with one worker per port is created for this call.
78
+ """
70
79
  ports = list(ports)
71
80
  if not ports:
72
81
  return []
73
82
 
74
- with concurrent.futures.ThreadPoolExecutor(max_workers=len(ports)) as executor:
75
- futures = {executor.submit(_probe, ip, port, timeout): port for port in ports}
76
- open_ports = [
77
- futures[future]
78
- for future in concurrent.futures.as_completed(futures)
79
- if future.result()
80
- ]
83
+ if executor is None:
84
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(ports)) as own:
85
+ return scan_ports(ip, ports, timeout, executor=own)
81
86
 
87
+ futures = {executor.submit(_probe, ip, port, timeout): port for port in ports}
88
+ open_ports = [
89
+ futures[future] for future in concurrent.futures.as_completed(futures) if future.result()
90
+ ]
82
91
  return sorted(open_ports)