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.
Files changed (72) hide show
  1. {ipmg-2.1.0/src/ipmg.egg-info → ipmg-2.1.1}/PKG-INFO +20 -10
  2. {ipmg-2.1.0 → ipmg-2.1.1}/README.md +19 -9
  3. {ipmg-2.1.0 → ipmg-2.1.1}/pyproject.toml +1 -1
  4. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/engine.py +47 -14
  6. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/portscan.py +19 -10
  7. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/utils/helpers.py +5 -1
  8. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/app.py +75 -9
  9. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/manager.py +24 -2
  10. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/server.py +37 -5
  11. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/index.html +2 -2
  12. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/api.js +63 -2
  13. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/app.js +2 -2
  14. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/views.js +25 -8
  15. {ipmg-2.1.0 → ipmg-2.1.1/src/ipmg.egg-info}/PKG-INFO +20 -10
  16. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/SOURCES.txt +1 -0
  17. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_engine.py +54 -1
  18. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_utils.py +12 -0
  19. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_web_api.py +95 -3
  20. ipmg-2.1.1/tests/test_web_manager.py +36 -0
  21. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_web_server.py +51 -0
  22. {ipmg-2.1.0 → ipmg-2.1.1}/LICENSE +0 -0
  23. {ipmg-2.1.0 → ipmg-2.1.1}/setup.cfg +0 -0
  24. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/__main__.py +0 -0
  25. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/__init__.py +0 -0
  26. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/commands.py +0 -0
  27. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/cli/parser.py +0 -0
  28. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/__init__.py +0 -0
  29. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/diff.py +0 -0
  30. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/discovery.py +0 -0
  31. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/ping.py +0 -0
  32. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/core/security.py +0 -0
  33. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/exceptions.py +0 -0
  34. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/__init__.py +0 -0
  35. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/database.py +0 -0
  36. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/file_io.py +0 -0
  37. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/infrastructure/incremental.py +0 -0
  38. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/__init__.py +0 -0
  39. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/diff_report.py +0 -0
  40. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/frames.py +0 -0
  41. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/live.py +0 -0
  42. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/summary.py +0 -0
  43. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/reporting/ui.py +0 -0
  44. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/__init__.py +0 -0
  45. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/history_service.py +0 -0
  46. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/services/scan_service.py +0 -0
  47. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/utils/__init__.py +0 -0
  48. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/__init__.py +0 -0
  49. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/db.py +0 -0
  50. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/css/app.css +0 -0
  51. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/charts.js +0 -0
  52. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg/web/static/js/demo.js +0 -0
  53. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/dependency_links.txt +0 -0
  54. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/entry_points.txt +0 -0
  55. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/requires.txt +0 -0
  56. {ipmg-2.1.0 → ipmg-2.1.1}/src/ipmg.egg-info/top_level.txt +0 -0
  57. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_commands.py +0 -0
  58. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_database_history.py +0 -0
  59. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_diff.py +0 -0
  60. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_diff_report.py +0 -0
  61. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_discover.py +0 -0
  62. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_file_io.py +0 -0
  63. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_history_service.py +0 -0
  64. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_incremental.py +0 -0
  65. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_live.py +0 -0
  66. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_parser.py +0 -0
  67. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ping.py +0 -0
  68. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ping_command.py +0 -0
  69. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_portscan.py +0 -0
  70. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_scan_service.py +0 -0
  71. {ipmg-2.1.0 → ipmg-2.1.1}/tests/test_ui.py +0 -0
  72. {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.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>
@@ -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` — this exposes an
472
- unauthenticated API on the network, so only do this on a trusted network or
473
- 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)).
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
- - WebSocket connections are origin-checked, so a web page you happen to
616
- 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
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`, anyone who can reach that
622
- interface can start scans and read results — put a reverse proxy with
623
- 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.
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` — this exposes an
425
- unauthenticated API on the network, so only do this on a trusted network or
426
- 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)).
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
- - WebSocket connections are origin-checked, so a web page you happen to
569
- 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
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`, anyone who can reach that
575
- interface can start scans and read results — put a reverse proxy with
576
- 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.
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.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"
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.1.0"
5
+ __version__ = "2.1.1"
@@ -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 = {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:
@@ -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(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)
@@ -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 OSError:
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
- await websocket.accept()
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(queue.put_nowait, event)
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
- app = create_app(database)
53
- url = f"http://{_display_host(host)}:{port}"
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
- ("Local", url),
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(f"Remote access: ssh -L {port}:127.0.0.1:{port} user@this-host, then open {url}.")
73
- ui.note("Or run with --host 0.0.0.0 (exposes an unauthenticated API — see SECURITY.md).")
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=20260911.1">
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=20260911.1"></script>
63
+ <script type="module" src="js/app.js?v=20260920.1"></script>
64
64
  </body>
65
65
  </html>