ipmg 2.1.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.
- {ipmg-2.1.0/src/ipmg.egg-info → ipmg-2.1.1}/PKG-INFO +20 -10
- {ipmg-2.1.0 → ipmg-2.1.1}/README.md +19 -9
- {ipmg-2.1.0 → ipmg-2.1.1}/pyproject.toml +1 -1
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/__init__.py +1 -1
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/engine.py +47 -14
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/portscan.py +19 -10
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/utils/helpers.py +5 -1
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/app.py +75 -9
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/manager.py +24 -2
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/server.py +37 -5
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/index.html +2 -2
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/api.js +63 -2
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/app.js +2 -2
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/views.js +25 -8
- {ipmg-2.1.0 → ipmg-2.1.1/src/ipmg.egg-info}/PKG-INFO +20 -10
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/SOURCES.txt +1 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_engine.py +54 -1
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_utils.py +12 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_web_api.py +95 -3
- ipmg-2.1.1/tests/test_web_manager.py +36 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_web_server.py +51 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/LICENSE +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/setup.cfg +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/__main__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/commands.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/parser.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/diff.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/discovery.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/ping.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/security.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/exceptions.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/database.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/file_io.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/incremental.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/diff_report.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/frames.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/live.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/summary.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/ui.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/history_service.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/scan_service.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/utils/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/__init__.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/db.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/css/app.css +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/charts.js +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/demo.js +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/dependency_links.txt +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/entry_points.txt +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/requires.txt +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/top_level.txt +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_commands.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_database_history.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_diff.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_diff_report.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_discover.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_file_io.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_history_service.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_incremental.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_live.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_parser.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ping.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ping_command.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_portscan.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_scan_service.py +0 -0
- {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ui.py +0 -0
- {ipmg-2.1.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.1.
|
|
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>
|
|
@@ -436,6 +436,12 @@ ipmg web # starts http://127.0.0.1:8080 and opens your browser
|
|
|
436
436
|
|
|
437
437
|
`ipmg --web` does the same thing, so whichever one you reach for first works.
|
|
438
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
|
+
|
|
439
445
|
It runs fully offline — every stylesheet and script is bundled with the
|
|
440
446
|
package, nothing is loaded from a CDN. It gives you:
|
|
441
447
|
|
|
@@ -465,12 +471,13 @@ it from your workstation with an SSH tunnel:
|
|
|
465
471
|
|
|
466
472
|
```bash
|
|
467
473
|
ssh -L 8080:127.0.0.1:8080 user@server
|
|
468
|
-
# 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
|
|
469
475
|
```
|
|
470
476
|
|
|
471
|
-
Alternatively, bind to all interfaces with `--host 0.0.0.0
|
|
472
|
-
|
|
473
|
-
|
|
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)).
|
|
474
481
|
|
|
475
482
|
---
|
|
476
483
|
|
|
@@ -612,15 +619,18 @@ itself is hardened accordingly:
|
|
|
612
619
|
validated as an IP address first
|
|
613
620
|
- IPMG Web binds to `127.0.0.1` by default and serves everything
|
|
614
621
|
locally — no CDN assets, no outbound requests
|
|
615
|
-
-
|
|
616
|
-
|
|
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
|
|
617
627
|
- Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
|
|
618
628
|
so a bad input file cannot exhaust memory
|
|
619
629
|
- All database access uses parameterized SQL
|
|
620
630
|
|
|
621
|
-
If you bind to a non-local address with `--host`,
|
|
622
|
-
|
|
623
|
-
|
|
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.
|
|
624
634
|
|
|
625
635
|
Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
|
|
626
636
|
|
|
@@ -389,6 +389,12 @@ ipmg web # starts http://127.0.0.1:8080 and opens your browser
|
|
|
389
389
|
|
|
390
390
|
`ipmg --web` does the same thing, so whichever one you reach for first works.
|
|
391
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
|
+
|
|
392
398
|
It runs fully offline — every stylesheet and script is bundled with the
|
|
393
399
|
package, nothing is loaded from a CDN. It gives you:
|
|
394
400
|
|
|
@@ -418,12 +424,13 @@ it from your workstation with an SSH tunnel:
|
|
|
418
424
|
|
|
419
425
|
```bash
|
|
420
426
|
ssh -L 8080:127.0.0.1:8080 user@server
|
|
421
|
-
# 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
|
|
422
428
|
```
|
|
423
429
|
|
|
424
|
-
Alternatively, bind to all interfaces with `--host 0.0.0.0
|
|
425
|
-
|
|
426
|
-
|
|
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)).
|
|
427
434
|
|
|
428
435
|
---
|
|
429
436
|
|
|
@@ -565,15 +572,18 @@ itself is hardened accordingly:
|
|
|
565
572
|
validated as an IP address first
|
|
566
573
|
- IPMG Web binds to `127.0.0.1` by default and serves everything
|
|
567
574
|
locally — no CDN assets, no outbound requests
|
|
568
|
-
-
|
|
569
|
-
|
|
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
|
|
570
580
|
- Uploads are capped at 5 MB and one scan expands to at most 65,536 hosts,
|
|
571
581
|
so a bad input file cannot exhaust memory
|
|
572
582
|
- All database access uses parameterized SQL
|
|
573
583
|
|
|
574
|
-
If you bind to a non-local address with `--host`,
|
|
575
|
-
|
|
576
|
-
|
|
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.
|
|
577
587
|
|
|
578
588
|
Found a vulnerability? See [SECURITY.md](SECURITY.md) for how to report it.
|
|
579
589
|
|
|
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
|
|
|
13
13
|
|
|
14
14
|
[project]
|
|
15
15
|
name = "ipmg"
|
|
16
|
-
version = "2.1.
|
|
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"
|
|
@@ -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,32 +70,27 @@ 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 =
|
|
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
|
-
|
|
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:
|
|
@@ -103,5 +103,38 @@ def execute_scan(
|
|
|
103
103
|
raise
|
|
104
104
|
finally:
|
|
105
105
|
executor.shutdown(wait=True)
|
|
106
|
+
if port_executor is not None:
|
|
107
|
+
port_executor.shutdown(wait=True)
|
|
106
108
|
|
|
107
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(
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
@@ -86,9 +86,13 @@ class HostnameCache:
|
|
|
86
86
|
with self._lock:
|
|
87
87
|
return self._cache.get(ip, (0.0, "Unresolvable"))[1]
|
|
88
88
|
|
|
89
|
+
# Set before the lookup so the finally block always has a value to
|
|
90
|
+
# cache and always releases threads waiting on this IP.
|
|
91
|
+
hostname = "Unresolvable"
|
|
89
92
|
try:
|
|
90
93
|
hostname = socket.gethostbyaddr(ip)[0]
|
|
91
|
-
except
|
|
94
|
+
except Exception:
|
|
95
|
+
# Any lookup failure means "no name", never a failed scan.
|
|
92
96
|
hostname = "Unresolvable"
|
|
93
97
|
finally:
|
|
94
98
|
with self._lock:
|
|
@@ -9,14 +9,15 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import io
|
|
11
11
|
import json
|
|
12
|
+
import secrets
|
|
12
13
|
import tempfile
|
|
13
14
|
from contextlib import asynccontextmanager
|
|
14
15
|
from pathlib import Path
|
|
15
|
-
from typing import Any, Dict, List, Optional
|
|
16
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
16
17
|
from urllib.parse import urlsplit
|
|
17
18
|
|
|
18
19
|
import pandas as pd
|
|
19
|
-
from fastapi import APIRouter, FastAPI, HTTPException, UploadFile, WebSocket
|
|
20
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, WebSocket
|
|
20
21
|
from fastapi.responses import Response
|
|
21
22
|
from fastapi.staticfiles import StaticFiles
|
|
22
23
|
from pydantic import BaseModel, Field
|
|
@@ -38,7 +39,7 @@ from ipmg.infrastructure.file_io import (
|
|
|
38
39
|
from ipmg.reporting.diff_report import DIFF_FORMATS, render_diff
|
|
39
40
|
from ipmg.reporting.frames import results_dataframe
|
|
40
41
|
from ipmg.services.history_service import HistoryService
|
|
41
|
-
from ipmg.web.manager import ScanManager
|
|
42
|
+
from ipmg.web.manager import OVERFLOW, ScanManager
|
|
42
43
|
|
|
43
44
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
44
45
|
|
|
@@ -58,6 +59,63 @@ DIFF_MEDIA_TYPES = {fmt: REPORT_MEDIA_TYPES[fmt] for fmt in DIFF_FORMATS}
|
|
|
58
59
|
#: Hostnames that count as "this machine" when checking WebSocket origins.
|
|
59
60
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
60
61
|
|
|
62
|
+
AUTH_ERROR = (
|
|
63
|
+
"Missing or invalid access token. Open IPMG Web with the link that "
|
|
64
|
+
"'ipmg web' printed when it started."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: WebSocket subprotocol the server answers with. Browsers cannot set headers
|
|
69
|
+
#: on a WebSocket, so the client offers ``ipmg`` plus ``ipmg.token.<token>``.
|
|
70
|
+
WS_SUBPROTOCOL = "ipmg"
|
|
71
|
+
_WS_AUTH_PREFIX = "ipmg.token."
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bearer_token(headers: Mapping[str, str]) -> Optional[str]:
|
|
75
|
+
"""The token from an ``Authorization: Bearer`` header.
|
|
76
|
+
|
|
77
|
+
Tokens are never read from the URL: query strings end up in access logs,
|
|
78
|
+
proxy logs, and browser history.
|
|
79
|
+
"""
|
|
80
|
+
scheme, _, credentials = headers.get("authorization", "").partition(" ")
|
|
81
|
+
if scheme.lower() == "bearer" and credentials.strip():
|
|
82
|
+
return credentials.strip()
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _websocket_token(websocket: WebSocket) -> Optional[str]:
|
|
87
|
+
"""A Bearer header (non-browser clients) or the ``ipmg.token.`` subprotocol."""
|
|
88
|
+
token = _bearer_token(websocket.headers)
|
|
89
|
+
if token is not None:
|
|
90
|
+
return token
|
|
91
|
+
for protocol in websocket.scope.get("subprotocols", []):
|
|
92
|
+
if protocol.startswith(_WS_AUTH_PREFIX):
|
|
93
|
+
return protocol[len(_WS_AUTH_PREFIX) :]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _token_valid(supplied: Optional[str], expected: str) -> bool:
|
|
98
|
+
return supplied is not None and secrets.compare_digest(
|
|
99
|
+
supplied.encode("utf-8"), expected.encode("utf-8")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_token(expected: str) -> Callable[[Request], None]:
|
|
104
|
+
"""FastAPI dependency that rejects API requests without the access token.
|
|
105
|
+
|
|
106
|
+
Every API route requires it, on loopback too: it stops other users on the
|
|
107
|
+
machine, web pages attempting CSRF, and DNS-rebinding attacks from driving
|
|
108
|
+
the scanner, and makes a non-loopback --host safe to use.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def check(request: Request) -> None:
|
|
112
|
+
if not _token_valid(_bearer_token(request.headers), expected):
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=401, detail=AUTH_ERROR, headers={"WWW-Authenticate": "Bearer"}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return check
|
|
118
|
+
|
|
61
119
|
|
|
62
120
|
class ScanRequest(BaseModel):
|
|
63
121
|
targets: Optional[str] = Field(default=None, description="Free-form target text")
|
|
@@ -335,19 +393,24 @@ def _origin_allowed(websocket: WebSocket) -> bool:
|
|
|
335
393
|
return origin_host in _LOOPBACK_HOSTS and server_host in _LOOPBACK_HOSTS
|
|
336
394
|
|
|
337
395
|
|
|
338
|
-
def _register_websocket(app: FastAPI, manager: ScanManager) -> None:
|
|
396
|
+
def _register_websocket(app: FastAPI, manager: ScanManager, token: str) -> None:
|
|
339
397
|
@app.websocket("/api/v1/ws")
|
|
340
398
|
async def websocket_events(websocket: WebSocket) -> None:
|
|
341
|
-
if not _origin_allowed(websocket):
|
|
399
|
+
if not _origin_allowed(websocket) or not _token_valid(_websocket_token(websocket), token):
|
|
342
400
|
await websocket.close(code=1008)
|
|
343
401
|
return
|
|
344
402
|
|
|
345
|
-
|
|
403
|
+
offered = websocket.scope.get("subprotocols", [])
|
|
404
|
+
await websocket.accept(subprotocol=WS_SUBPROTOCOL if WS_SUBPROTOCOL in offered else None)
|
|
346
405
|
queue = manager.subscribe()
|
|
347
406
|
|
|
348
407
|
async def forward_events() -> None:
|
|
349
408
|
while True:
|
|
350
409
|
event = await queue.get()
|
|
410
|
+
if event is OVERFLOW:
|
|
411
|
+
# 1013 "try again later": the client fell too far behind.
|
|
412
|
+
await websocket.close(code=1013)
|
|
413
|
+
return
|
|
351
414
|
await websocket.send_json(event)
|
|
352
415
|
|
|
353
416
|
forward_task = asyncio.create_task(forward_events())
|
|
@@ -363,8 +426,10 @@ def _register_websocket(app: FastAPI, manager: ScanManager) -> None:
|
|
|
363
426
|
manager.unsubscribe(queue)
|
|
364
427
|
|
|
365
428
|
|
|
366
|
-
def create_app(db: Optional[Database] = None) -> FastAPI:
|
|
429
|
+
def create_app(db: Optional[Database] = None, token: Optional[str] = None) -> FastAPI:
|
|
430
|
+
"""Build the app. ``token`` guards every API route; a random one is made if omitted."""
|
|
367
431
|
database = db if db is not None else Database(DEFAULT_DB_PATH)
|
|
432
|
+
token = token or secrets.token_urlsafe(32)
|
|
368
433
|
manager = ScanManager(database)
|
|
369
434
|
history = HistoryService(database)
|
|
370
435
|
|
|
@@ -377,8 +442,9 @@ def create_app(db: Optional[Database] = None) -> FastAPI:
|
|
|
377
442
|
app.state.db = database
|
|
378
443
|
app.state.manager = manager
|
|
379
444
|
app.state.history = history
|
|
445
|
+
app.state.token = token
|
|
380
446
|
|
|
381
|
-
api = APIRouter(prefix="/api/v1")
|
|
447
|
+
api = APIRouter(prefix="/api/v1", dependencies=[Depends(_require_token(token))])
|
|
382
448
|
_register_overview_routes(api, database)
|
|
383
449
|
_register_scan_routes(api, database, manager)
|
|
384
450
|
_register_report_routes(api, database)
|
|
@@ -386,7 +452,7 @@ def create_app(db: Optional[Database] = None) -> FastAPI:
|
|
|
386
452
|
_register_upload_route(api)
|
|
387
453
|
app.include_router(api)
|
|
388
454
|
|
|
389
|
-
_register_websocket(app, manager)
|
|
455
|
+
_register_websocket(app, manager, token)
|
|
390
456
|
|
|
391
457
|
if STATIC_DIR.is_dir():
|
|
392
458
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
|
@@ -11,6 +11,14 @@ from typing import Any, Dict, List, Optional, Set
|
|
|
11
11
|
from ipmg.core.engine import HostResult, ScanConfig, execute_scan
|
|
12
12
|
from ipmg.web.db import Database
|
|
13
13
|
|
|
14
|
+
#: Events buffered per WebSocket subscriber. A client this far behind is
|
|
15
|
+
#: disconnected instead of letting its backlog grow without bound; the browser
|
|
16
|
+
#: reconnects and reloads the scan from the REST API.
|
|
17
|
+
SUBSCRIBER_QUEUE_LIMIT = 1000
|
|
18
|
+
|
|
19
|
+
#: Queued in place of an event to tell the WebSocket handler to close.
|
|
20
|
+
OVERFLOW = None
|
|
21
|
+
|
|
14
22
|
|
|
15
23
|
class ScanManager:
|
|
16
24
|
def __init__(self, db: Database) -> None:
|
|
@@ -26,7 +34,7 @@ class ScanManager:
|
|
|
26
34
|
self._loop = loop
|
|
27
35
|
|
|
28
36
|
def subscribe(self) -> asyncio.Queue:
|
|
29
|
-
queue: asyncio.Queue = asyncio.Queue()
|
|
37
|
+
queue: asyncio.Queue = asyncio.Queue(maxsize=SUBSCRIBER_QUEUE_LIMIT)
|
|
30
38
|
with self._lock:
|
|
31
39
|
self._subscribers.add(queue)
|
|
32
40
|
return queue
|
|
@@ -42,7 +50,21 @@ class ScanManager:
|
|
|
42
50
|
with self._lock:
|
|
43
51
|
queues = list(self._subscribers)
|
|
44
52
|
for queue in queues:
|
|
45
|
-
loop.call_soon_threadsafe(
|
|
53
|
+
loop.call_soon_threadsafe(self._offer, queue, event)
|
|
54
|
+
|
|
55
|
+
def _offer(self, queue: asyncio.Queue, event: Dict[str, Any]) -> None:
|
|
56
|
+
"""Queue an event for one subscriber; runs on the event loop."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
if queue not in self._subscribers:
|
|
59
|
+
return
|
|
60
|
+
if queue.full():
|
|
61
|
+
# The client is not reading. Drop its backlog and close it.
|
|
62
|
+
self.unsubscribe(queue)
|
|
63
|
+
while not queue.empty():
|
|
64
|
+
queue.get_nowait()
|
|
65
|
+
queue.put_nowait(OVERFLOW)
|
|
66
|
+
return
|
|
67
|
+
queue.put_nowait(event)
|
|
46
68
|
|
|
47
69
|
# -------------------------------------------------------------- scans
|
|
48
70
|
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import ipaddress
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
7
9
|
import sys
|
|
8
10
|
import threading
|
|
9
11
|
import webbrowser
|
|
@@ -35,6 +37,26 @@ def _is_loopback(host: str) -> bool:
|
|
|
35
37
|
return False
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
#: Characters that survive a URL fragment and a WebSocket subprotocol unescaped.
|
|
41
|
+
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._~-]{16,}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _access_token() -> str:
|
|
45
|
+
"""``IPMG_WEB_TOKEN`` when set (e.g. behind a reverse proxy), else a fresh one.
|
|
46
|
+
|
|
47
|
+
Like Jupyter, every start gets a new random token unless one is pinned.
|
|
48
|
+
"""
|
|
49
|
+
pinned = os.environ.get("IPMG_WEB_TOKEN", "").strip()
|
|
50
|
+
if not pinned:
|
|
51
|
+
return secrets.token_urlsafe(32)
|
|
52
|
+
if not _TOKEN_PATTERN.fullmatch(pinned):
|
|
53
|
+
raise SystemExit(
|
|
54
|
+
"IPMG_WEB_TOKEN must be at least 16 characters of letters, digits, '.', '_', '~' "
|
|
55
|
+
"or '-'. Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
|
56
|
+
)
|
|
57
|
+
return pinned
|
|
58
|
+
|
|
59
|
+
|
|
38
60
|
def _has_display() -> bool:
|
|
39
61
|
"""False on a Linux session with no X11/Wayland display (e.g. over plain SSH)."""
|
|
40
62
|
if sys.platform.startswith("linux"):
|
|
@@ -49,13 +71,16 @@ def run_dashboard(
|
|
|
49
71
|
db_path: Optional[str] = None,
|
|
50
72
|
) -> None:
|
|
51
73
|
database = Database(Path(db_path) if db_path else DEFAULT_DB_PATH)
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
token = _access_token()
|
|
75
|
+
app = create_app(database, token)
|
|
76
|
+
# The token goes in the fragment, which browsers never send to the server,
|
|
77
|
+
# so it stays out of access logs and proxies.
|
|
78
|
+
url = f"http://{_display_host(host)}:{port}/#token={token}"
|
|
54
79
|
|
|
55
80
|
ui.blank()
|
|
56
81
|
ui.fields(
|
|
57
82
|
[
|
|
58
|
-
("
|
|
83
|
+
("Open", url),
|
|
59
84
|
("History", database.path),
|
|
60
85
|
]
|
|
61
86
|
)
|
|
@@ -68,9 +93,16 @@ def run_dashboard(
|
|
|
68
93
|
elif open_browser:
|
|
69
94
|
threading.Timer(1.0, webbrowser.open, args=(url,)).start()
|
|
70
95
|
|
|
96
|
+
ui.note("The link carries this session's access token; the API refuses requests without it.")
|
|
71
97
|
if _is_loopback(host):
|
|
72
|
-
ui.note(
|
|
73
|
-
|
|
98
|
+
ui.note(
|
|
99
|
+
f"Remote access: ssh -L {port}:127.0.0.1:{port} user@this-host, then open the link."
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
ui.note(
|
|
103
|
+
"Listening beyond this machine over plain HTTP: anyone who can see the traffic "
|
|
104
|
+
"can read the token. Prefer an SSH tunnel, or a reverse proxy with TLS (see SECURITY.md)."
|
|
105
|
+
)
|
|
74
106
|
|
|
75
107
|
ui.blank()
|
|
76
108
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>IPMG Web</title>
|
|
7
|
-
<link rel="stylesheet" href="css/app.css?v=
|
|
7
|
+
<link rel="stylesheet" href="css/app.css?v=20260920.1">
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='13' fill='none' stroke='%232a78d6' stroke-width='3'/%3E%3Ccircle cx='16' cy='16' r='5' fill='%232a78d6'/%3E%3C/svg%3E">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
</div>
|
|
61
61
|
<div id="tooltip" class="tooltip" hidden></div>
|
|
62
62
|
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
|
|
63
|
-
<script type="module" src="js/app.js?v=
|
|
63
|
+
<script type="module" src="js/app.js?v=20260920.1"></script>
|
|
64
64
|
</body>
|
|
65
65
|
</html>
|