findssh 2.0.1__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {findssh-2.0.1/src/findssh.egg-info → findssh-2.1.0}/PKG-INFO +19 -14
  2. {findssh-2.0.1 → findssh-2.1.0}/README.md +13 -5
  3. {findssh-2.0.1 → findssh-2.1.0}/pyproject.toml +5 -8
  4. findssh-2.1.0/src/findssh/__init__.py +31 -0
  5. findssh-2.1.0/src/findssh/__main__.py +163 -0
  6. {findssh-2.0.1 → findssh-2.1.0}/src/findssh/base.py +22 -12
  7. findssh-2.1.0/src/findssh/coro.py +250 -0
  8. findssh-2.1.0/src/findssh/http.py +60 -0
  9. findssh-2.1.0/src/findssh/tests/conftest.py +84 -0
  10. findssh-2.1.0/src/findssh/tests/test_discovery.py +134 -0
  11. findssh-2.0.1/src/findssh/tests/test_coro.py → findssh-2.1.0/src/findssh/tests/test_ssh_coroutine.py +7 -0
  12. findssh-2.0.1/src/findssh/tests/test_threadpool.py → findssh-2.1.0/src/findssh/tests/test_ssh_threadpool.py +1 -0
  13. {findssh-2.0.1 → findssh-2.1.0}/src/findssh/threadpool.py +7 -5
  14. {findssh-2.0.1 → findssh-2.1.0/src/findssh.egg-info}/PKG-INFO +19 -14
  15. {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/SOURCES.txt +5 -2
  16. findssh-2.1.0/src/findssh.egg-info/requires.txt +5 -0
  17. findssh-2.0.1/src/findssh/__init__.py +0 -34
  18. findssh-2.0.1/src/findssh/__main__.py +0 -73
  19. findssh-2.0.1/src/findssh/coro.py +0 -67
  20. findssh-2.0.1/src/findssh.egg-info/requires.txt +0 -10
  21. {findssh-2.0.1 → findssh-2.1.0}/LICENSE.txt +0 -0
  22. {findssh-2.0.1 → findssh-2.1.0}/setup.cfg +0 -0
  23. {findssh-2.0.1 → findssh-2.1.0}/src/findssh/tests/__init__.py +0 -0
  24. {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/dependency_links.txt +0 -0
  25. {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/entry_points.txt +0 -0
  26. {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: findssh
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: find open servers on IPv4 subnet such as SSH
5
5
  Keywords: port-scan,ssh
6
6
  Classifier: Development Status :: 5 - Production/Stable
@@ -18,29 +18,28 @@ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Topic :: Security
19
19
  Classifier: Topic :: System :: Networking
20
20
  Classifier: Topic :: Utilities
21
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE.txt
24
24
  Provides-Extra: tests
25
25
  Requires-Dist: pytest; extra == "tests"
26
- Provides-Extra: lint
27
- Requires-Dist: flake8; extra == "lint"
28
- Requires-Dist: flake8-bugbear; extra == "lint"
29
- Requires-Dist: flake8-builtins; extra == "lint"
30
- Requires-Dist: flake8-blind-except; extra == "lint"
31
- Requires-Dist: mypy; extra == "lint"
26
+ Requires-Dist: pytest-asyncio; extra == "tests"
27
+ Requires-Dist: mypy; extra == "tests"
28
+ Dynamic: license-file
32
29
 
33
30
  # Find SSH servers (without NMAP)
34
31
 
35
32
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3336467.svg)](https://doi.org/10.5281/zenodo.3336467)
36
- ![ci](https://github.com/scivision/findssh/workflows/ci/badge.svg)
37
- [![PyPI Download stats](http://pepy.tech/badge/findssh)](http://pepy.tech/project/findssh)
33
+ [![ci](https://github.com/scivision/findssh/actions/workflows/ci.yml/badge.svg)](https://github.com/scivision/findssh/actions/workflows/ci.yml)
34
+ [![PyPI Download stats](https://pepy.tech/badge/findssh)](https://pepy.tech/project/findssh)
38
35
 
39
36
  Platform-independently find SSH servers (or other services with open ports) on an IPv4 subnet in pure Python WITHOUT NMAP.
40
37
  Scan entire IPv4 subnet in less than 1 second using Python standard library `asyncio` coroutines and a single thread.
41
38
 
42
- The default
43
- [asyncio coroutine](https://docs.python.org/3/library/asyncio.html)
39
+ The findssh
40
+ [asyncio](https://docs.python.org/3/library/asyncio.html)
41
+ coroutine
42
+ [TaskGroup](https://docs.python.org/3/library/asyncio-task.html#task-groups)
44
43
  uses a single thread and is more than 10x faster than
45
44
  [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html).
46
45
 
@@ -55,7 +54,7 @@ or from this repo:
55
54
  ```sh
56
55
  git clone https://github.com/scivision/findssh
57
56
 
58
- pip install -e findssh
57
+ pip install -e ./findssh
59
58
  ```
60
59
 
61
60
  ## Usage
@@ -76,6 +75,12 @@ or use project script e.g. from [pipx](https://github.com/pypa/pipx):
76
75
  findssh
77
76
  ```
78
77
 
78
+ For example, to scan your local subnet for SSH servers:
79
+
80
+ ```sh
81
+ findssh -p 22 192.168.1.0
82
+ ```
83
+
79
84
  * `-s` check the string from the server to attempt to verify the correct service has been found
80
85
  * `-t` timeout per server (seconds) useful for high latency connection
81
86
  * `-b` baseip (check other subnet besides your own)
@@ -1,14 +1,16 @@
1
1
  # Find SSH servers (without NMAP)
2
2
 
3
3
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3336467.svg)](https://doi.org/10.5281/zenodo.3336467)
4
- ![ci](https://github.com/scivision/findssh/workflows/ci/badge.svg)
5
- [![PyPI Download stats](http://pepy.tech/badge/findssh)](http://pepy.tech/project/findssh)
4
+ [![ci](https://github.com/scivision/findssh/actions/workflows/ci.yml/badge.svg)](https://github.com/scivision/findssh/actions/workflows/ci.yml)
5
+ [![PyPI Download stats](https://pepy.tech/badge/findssh)](https://pepy.tech/project/findssh)
6
6
 
7
7
  Platform-independently find SSH servers (or other services with open ports) on an IPv4 subnet in pure Python WITHOUT NMAP.
8
8
  Scan entire IPv4 subnet in less than 1 second using Python standard library `asyncio` coroutines and a single thread.
9
9
 
10
- The default
11
- [asyncio coroutine](https://docs.python.org/3/library/asyncio.html)
10
+ The findssh
11
+ [asyncio](https://docs.python.org/3/library/asyncio.html)
12
+ coroutine
13
+ [TaskGroup](https://docs.python.org/3/library/asyncio-task.html#task-groups)
12
14
  uses a single thread and is more than 10x faster than
13
15
  [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html).
14
16
 
@@ -23,7 +25,7 @@ or from this repo:
23
25
  ```sh
24
26
  git clone https://github.com/scivision/findssh
25
27
 
26
- pip install -e findssh
28
+ pip install -e ./findssh
27
29
  ```
28
30
 
29
31
  ## Usage
@@ -44,6 +46,12 @@ or use project script e.g. from [pipx](https://github.com/pypa/pipx):
44
46
  findssh
45
47
  ```
46
48
 
49
+ For example, to scan your local subnet for SSH servers:
50
+
51
+ ```sh
52
+ findssh -p 22 192.168.1.0
53
+ ```
54
+
47
55
  * `-s` check the string from the server to attempt to verify the correct service has been found
48
56
  * `-t` timeout per server (seconds) useful for high latency connection
49
57
  * `-b` baseip (check other subnet besides your own)
@@ -24,14 +24,13 @@ classifiers = [
24
24
  "Topic :: Utilities"
25
25
  ]
26
26
  dynamic = ["readme", "version"]
27
- requires-python = ">=3.9"
27
+ requires-python = ">=3.11"
28
28
 
29
29
  [project.scripts]
30
30
  findssh = "findssh.__main__:main"
31
31
 
32
32
  [project.optional-dependencies]
33
- tests = ["pytest"]
34
- lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy"]
33
+ tests = ["pytest", "pytest-asyncio", "mypy"]
35
34
 
36
35
  [tool.setuptools.dynamic]
37
36
  readme = {file = ["README.md"], content-type = "text/markdown"}
@@ -41,8 +40,6 @@ version = {attr = "findssh.__version__"}
41
40
  line-length = 90
42
41
 
43
42
  [tool.mypy]
44
- files = ["src"]
45
- strict_optional = false
46
- allow_redefinition = true
47
- show_error_context = false
48
- show_column_numbers = true
43
+ files = ["."]
44
+ install_types = true
45
+ exclude = ["^build([/\\\\]|$)"]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ import socket
3
+ import ipaddress
4
+
5
+ from .coro import get_hosts, discover_services
6
+
7
+ __all__ = ["get_hosts", "discover_services", "address2net", "get_lan_ip"]
8
+
9
+ __version__ = "2.1.0"
10
+
11
+
12
+ def get_lan_ip() -> ipaddress.IPv4Address | ipaddress.IPv6Address:
13
+ """
14
+ get IP address of currently used LAN interface
15
+ ref: https://stackoverflow.com/a/23822431
16
+ """
17
+
18
+ name = socket.gethostname()
19
+ host = socket.gethostbyname(name)
20
+
21
+ return ipaddress.ip_address(host)
22
+
23
+
24
+ def address2net(
25
+ addr: ipaddress.IPv4Address | ipaddress.IPv6Address, mask: str = "24"
26
+ ) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
27
+
28
+ if isinstance(addr, (ipaddress.IPv4Address, ipaddress.IPv6Address)):
29
+ return ipaddress.ip_network(f"{addr}/{mask}", strict=False)
30
+
31
+ raise TypeError(f"Unsupported address type: {type(addr).__name__} (value: {addr})")
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scans IPv4 subnet for SSH servers on Port 22 or other server ports.
4
+ Useful for machines that don't/can't have NMAP installed (e.g. Windows),
5
+ and device does not have Avahi server.
6
+ I wanted to make it as cross-platform as possible,
7
+ where the user would have only basic Python installed (Windows)
8
+
9
+ Note:
10
+ timeout value bare minimum is 0.15 seconds for LAN,
11
+ suggest using higher values say 0.25 or 0.35 to allow for network / CPU delays
12
+ Wifi timeout should be 1 second or more
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+ import ipaddress as ip
18
+ from argparse import ArgumentParser
19
+
20
+ from . import get_lan_ip, address2net, discover_services
21
+ from . import coro
22
+ from . import threadpool
23
+
24
+ PORT = 22
25
+ TIMEOUT = 1.0
26
+
27
+ # Default ports for known services
28
+ DEFAULT_SERVICE_PORTS = {
29
+ "ssh": 22,
30
+ "http": 80,
31
+ "https": 443,
32
+ "dns": 53,
33
+ }
34
+
35
+
36
+ def parse_services(services_str: str) -> dict[str, int | list[int]]:
37
+ """
38
+ Parse service specification string.
39
+
40
+ Format: "service[:port[,port,...]]" (space-separated)
41
+ Examples:
42
+ "ssh" → {"ssh": 22}
43
+ "ssh:22" → {"ssh": 22}
44
+ "http:80,443" → {"http": [80, 443]}
45
+ "ssh http:80,443" → {"ssh": 22, "http": [80, 443]}
46
+ "ssh:2222 http:8080,8443" → {"ssh": 2222, "http": [8080, 8443]}
47
+ """
48
+ result: dict[str, int | list[int]] = {}
49
+
50
+ for spec in services_str.split():
51
+ if ":" in spec:
52
+ service_name, ports_str = spec.split(":", 1)
53
+ ports_list = [int(p.strip()) for p in ports_str.split(",")]
54
+ if len(ports_list) == 1:
55
+ result[service_name] = ports_list[0]
56
+ else:
57
+ result[service_name] = ports_list
58
+ else:
59
+ # Use default port for service
60
+ service_name = spec
61
+ if service_name in DEFAULT_SERVICE_PORTS:
62
+ result[service_name] = DEFAULT_SERVICE_PORTS[service_name]
63
+ else:
64
+ raise ValueError(
65
+ f"Unknown service: {service_name}. Use service:port format."
66
+ )
67
+
68
+ if not result:
69
+ raise ValueError("No services specified")
70
+
71
+ return result
72
+
73
+
74
+ def main():
75
+ p = ArgumentParser("scan for hosts with open port, without NMAP")
76
+
77
+ # Single port mode (original behavior)
78
+ p.add_argument("-p", "--port", help="single port to try", default=PORT, type=int)
79
+ p.add_argument(
80
+ "-s", "--service", default="", help="string to match to qualify detections"
81
+ )
82
+
83
+ # Multi-service mode (new feature)
84
+ p.add_argument(
85
+ "--services",
86
+ help=(
87
+ "discover multiple services. Format: 'service[:port[,port,...]]' (space-separated). "
88
+ "Examples: 'ssh', 'ssh http:80,443', 'ssh:2222 http:8080'. "
89
+ "Defaults: ssh=22, http=80, https=443, dns=53"
90
+ ),
91
+ type=str,
92
+ default="",
93
+ )
94
+
95
+ p.add_argument(
96
+ "-t",
97
+ "--timeout",
98
+ help="timeout to wait for server. Must be finite or will hang.",
99
+ type=float,
100
+ default=TIMEOUT,
101
+ )
102
+ p.add_argument("-b", "--baseip", help="set a specific subnet to scan")
103
+ p.add_argument("-v", "--verbose", action="store_true")
104
+ p.add_argument(
105
+ "-threadpool", help="use threadpool instead of asyncio", action="store_true"
106
+ )
107
+ P = p.parse_args()
108
+
109
+ ll = logging.DEBUG if P.verbose else logging.INFO
110
+ logging.basicConfig(level=ll)
111
+
112
+ if not P.baseip:
113
+ ownip = get_lan_ip()
114
+ print("own address", ownip)
115
+ else:
116
+ ownip = ip.ip_address(P.baseip)
117
+
118
+ net = address2net(ownip)
119
+ print("searching", net)
120
+
121
+ # Multi-service discovery mode
122
+ if P.services:
123
+ if P.threadpool:
124
+ print(
125
+ "Warning: threadpool not supported for multi-service discovery, using asyncio"
126
+ )
127
+
128
+ try:
129
+ service_ports = parse_services(P.services)
130
+ except ValueError as e:
131
+ print(f"Error parsing services: {e}")
132
+ return
133
+
134
+ print(f"discovering services: {', '.join(service_ports.keys())}")
135
+ results = asyncio.run(discover_services(net, service_ports, P.timeout))
136
+
137
+ # Display results
138
+ print()
139
+ for service_name in sorted(results.keys()):
140
+ hosts = results[service_name]
141
+ print(f"{service_name}:")
142
+ if hosts:
143
+ for host, banner in hosts:
144
+ print(f" {host}: {banner}")
145
+ else:
146
+ print(" (none found)")
147
+
148
+ # Original single-port mode
149
+ else:
150
+ if P.threadpool:
151
+ for host in threadpool.get_hosts(
152
+ net,
153
+ P.port,
154
+ P.timeout,
155
+ P.service,
156
+ ):
157
+ print(host)
158
+ else:
159
+ asyncio.run(coro.get_hosts(net, P.port, P.timeout, P.service))
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
@@ -1,26 +1,31 @@
1
- from __future__ import annotations
2
1
  import ipaddress as ip
3
2
  import socket
4
- import typing as T
3
+ import logging
4
+ import collections.abc
5
5
 
6
+ HostResult = tuple[ip.IPv4Address, str]
6
7
 
7
- def get_service(b: bytes, service: str | None = None) -> str:
8
+
9
+ def get_service(b: bytes, service: str | None = None) -> str | None:
8
10
  """
9
11
  splitlines is in case the ASCII/UTF8 response is less than 32 bytes,
10
12
  hoping server sends a \r\n
11
13
  """
12
14
 
13
- svc_txt = b.splitlines()[0].decode("utf-8", errors="ignore")
14
- # %% optional service validation
15
- if service and service not in svc_txt.lower():
16
- return None
15
+ if lines := b.splitlines():
16
+ svc_txt = lines[0].decode("utf-8", errors="ignore")
17
+ # %% optional service validation
18
+ if service and service not in svc_txt.lower():
19
+ return None
20
+
21
+ return svc_txt
17
22
 
18
- return svc_txt
23
+ return None
19
24
 
20
25
 
21
26
  def is_port_open(
22
27
  host: ip.IPv4Address, port: int, timeout: float, service: str | None = None
23
- ) -> tuple[ip.IPv4Address, str]:
28
+ ) -> HostResult | None:
24
29
  """
25
30
  is a port open? Without coroutines.
26
31
  """
@@ -36,9 +41,12 @@ def is_port_open(
36
41
  return None
37
42
 
38
43
  try:
44
+ # If recv returns empty bytes, the connection was closed by the remote host.
45
+ # This does not necessarily mean the port is closed, but no service banner was received.
39
46
  if not (resp := s.recv(32)):
40
47
  return None
41
- except (socket.timeout, ConnectionError):
48
+ except (socket.timeout, ConnectionError) as err:
49
+ logging.debug("Socket error: %s", err)
42
50
  return None
43
51
 
44
52
  if svc_txt := get_service(resp, service):
@@ -49,9 +57,11 @@ def is_port_open(
49
57
 
50
58
  def get_hosts_seq(
51
59
  net: ip.IPv4Network, port: int, timeout: float, service: str | None = None
52
- ) -> T.Iterable[tuple[ip.IPv4Address, str]]:
60
+ ) -> collections.abc.Iterable[HostResult]:
53
61
  """
54
- find hosts sequentially (no parallelism or concurrency)
62
+ Yields hosts in the network that have the specified port open and
63
+ match the service (if provided),
64
+ sequentially (no parallelism or concurrency).
55
65
  """
56
66
 
57
67
  for host in net.hosts():
@@ -0,0 +1,250 @@
1
+ """
2
+ this method uses Python asyncio coroutines and is significantly faster and leaner,
3
+ here using only ONE thread total, instead of the slower one-thread-per-address
4
+ threadpool.py method
5
+ """
6
+
7
+ import logging
8
+ import ipaddress as ip
9
+ import asyncio
10
+ from collections.abc import Mapping
11
+
12
+ from .base import HostResult, get_service
13
+ from .http import is_http_response, get_http_service
14
+
15
+ __all__ = ["get_hosts", "discover_services"]
16
+
17
+
18
+ async def get_hosts(
19
+ net: ip.IPv4Network,
20
+ port: int,
21
+ timeout: float,
22
+ service: str | None = None,
23
+ max_concurrent: int = 100,
24
+ ) -> list[HostResult]:
25
+ """
26
+ Timeout must be finite otherwise non-existant hosts are waited for forever
27
+
28
+ use of Semaphore limits number of concurrent connections to avoid
29
+ overloading system with large nets
30
+ 100 is a reasonable default for most systems
31
+ """
32
+
33
+ if max_concurrent < 1:
34
+ raise ValueError(f"max_concurrent must be >= 1, got {max_concurrent}")
35
+
36
+ hosts: list[HostResult] = []
37
+ host_queue: asyncio.Queue[ip.IPv4Address] = asyncio.Queue()
38
+ for host in net.hosts():
39
+ host_queue.put_nowait(host)
40
+
41
+ async def worker() -> None:
42
+ while True:
43
+ try:
44
+ host = host_queue.get_nowait()
45
+ except asyncio.QueueEmpty:
46
+ return
47
+
48
+ if result := await waiter(host, port, service, timeout):
49
+ logging.info("Found host: %s", result)
50
+ hosts.append(result)
51
+
52
+ worker_count = min(max_concurrent, max(1, host_queue.qsize()))
53
+ async with asyncio.TaskGroup() as tg:
54
+ for _ in range(worker_count):
55
+ tg.create_task(worker())
56
+
57
+ return hosts
58
+
59
+
60
+ async def waiter(
61
+ host: ip.IPv4Address, port: int, service: str | None, timeout: float
62
+ ) -> HostResult | None:
63
+ try:
64
+ async with asyncio.timeout(timeout):
65
+ return await is_port_open(host, port, service)
66
+ except TimeoutError:
67
+ return None
68
+
69
+
70
+ async def is_port_open(
71
+ host: ip.IPv4Address, port: int, service: str | None
72
+ ) -> HostResult | None:
73
+ """
74
+ https://docs.python.org/3/library/asyncio-stream.html#asyncio.open_connection
75
+ """
76
+ host_str = host.exploded
77
+
78
+ try:
79
+ reader, writer = await asyncio.open_connection(host_str, port)
80
+ if not (b := await reader.read(32)):
81
+ return None
82
+ except OSError as err: # to avoid flake8 error OSError has ConnectionError
83
+ logging.debug("Error connecting to %s:%s - %s", host_str, port, err)
84
+ return None
85
+ finally:
86
+ try:
87
+ writer.close()
88
+ await writer.wait_closed()
89
+ except (NameError, OSError):
90
+ pass
91
+
92
+ if svc_txt := get_service(b, service):
93
+ return host, svc_txt
94
+
95
+ return None
96
+
97
+
98
+ async def discover_services(
99
+ net: ip.IPv4Network,
100
+ service_ports: Mapping[str, int | list[int]],
101
+ timeout: float,
102
+ max_concurrent: int = 100,
103
+ ) -> dict[str, list[HostResult]]:
104
+ """
105
+ Discover multiple services (e.g., SSH, HTTP) across a network in a single pass.
106
+
107
+ Scans a subnet for multiple services simultaneously, using a worker pool pattern
108
+ with asyncio.TaskGroup to efficiently parallelize connections across all hosts
109
+ and ports.
110
+
111
+ Args:
112
+ net: IPv4Network to scan (e.g., IPv4Network('192.168.1.0/24'))
113
+ service_ports: Mapping of service names to port(s)
114
+ Examples:
115
+ - {"ssh": 22, "http": 80}
116
+ - {"ssh": 22, "http": [80, 443]}
117
+ - {"dns": 53, "ssh": 22}
118
+ timeout: Connection timeout in seconds (must be finite)
119
+ max_concurrent: Maximum number of concurrent connections (default: 100)
120
+
121
+ Returns:
122
+ Dictionary mapping service names to lists of (IPv4Address, banner_text) tuples
123
+ Example: {
124
+ "ssh": [(IPv4Address('192.168.1.1'), 'OpenSSH_8.0...'), ...],
125
+ "http": [(IPv4Address('192.168.1.3'), 'HTTP/1.1 200 OK'), ...]
126
+ }
127
+
128
+ Raises:
129
+ ValueError: If max_concurrent < 1
130
+ """
131
+ if max_concurrent < 1:
132
+ raise ValueError(f"max_concurrent must be >= 1, got {max_concurrent}")
133
+
134
+ # Normalize service_ports to dict[service_name, list[int]]
135
+ normalized_ports: dict[str, list[int]] = {}
136
+ for service_name, ports in service_ports.items():
137
+ if isinstance(ports, int):
138
+ normalized_ports[service_name] = [ports]
139
+ elif isinstance(ports, list):
140
+ normalized_ports[service_name] = ports
141
+ else:
142
+ raise TypeError(
143
+ f"Port for service '{service_name}' must be int or list[int], "
144
+ f"got {type(ports).__name__}"
145
+ )
146
+
147
+ # Build work queue: (host, port, service_name) tuples
148
+ work_queue: asyncio.Queue[tuple[ip.IPv4Address, int, str]] = asyncio.Queue()
149
+ for host in net.hosts():
150
+ for service_name, ports in normalized_ports.items():
151
+ for port in ports:
152
+ work_queue.put_nowait((host, port, service_name))
153
+
154
+ # Results dict
155
+ results: dict[str, list[HostResult]] = {svc: [] for svc in normalized_ports}
156
+
157
+ async def worker() -> None:
158
+ """Worker coroutine: process work queue items until empty."""
159
+ while True:
160
+ try:
161
+ host, port, service_name = work_queue.get_nowait()
162
+ except asyncio.QueueEmpty:
163
+ return
164
+
165
+ if result := await discover_waiter(host, port, service_name, timeout):
166
+ logging.info("Found %s host: %s", service_name, result)
167
+ results[service_name].append(result)
168
+
169
+ # Calculate worker count based on queue size and max_concurrent
170
+ worker_count = min(max_concurrent, max(1, work_queue.qsize()))
171
+
172
+ # Run workers concurrently
173
+ async with asyncio.TaskGroup() as tg:
174
+ for _ in range(worker_count):
175
+ tg.create_task(worker())
176
+
177
+ return results
178
+
179
+
180
+ async def discover_waiter(
181
+ host: ip.IPv4Address, port: int, service_name: str, timeout: float
182
+ ) -> HostResult | None:
183
+ """
184
+ Attempt to connect to a service on a host with timeout.
185
+
186
+ Routes service validation based on service type.
187
+ """
188
+ try:
189
+ async with asyncio.timeout(timeout):
190
+ return await discover_is_port_open(host, port, service_name)
191
+ except TimeoutError:
192
+ return None
193
+
194
+
195
+ async def discover_is_port_open(
196
+ host: ip.IPv4Address, port: int, service_name: str
197
+ ) -> HostResult | None:
198
+ """
199
+ Check if a port is open and identify the service.
200
+
201
+ Routes to service-specific validators:
202
+ - "http": sends HTTP GET request, then validates response headers
203
+ - "ssh": expects SSH banner (service name validation)
204
+ - others: accepts any response as valid (generic port detection)
205
+ """
206
+ host_str = host.exploded
207
+
208
+ try:
209
+ reader, writer = await asyncio.open_connection(host_str, port)
210
+
211
+ # For HTTP, send a GET request; for others, just read the banner
212
+ if service_name.lower() == "http":
213
+ # Send minimal HTTP request to trigger server response
214
+ http_request = b"GET / HTTP/1.0\r\n\r\n"
215
+ writer.write(http_request)
216
+ await writer.drain()
217
+
218
+ if not (b := await reader.read(32)):
219
+ return None
220
+ except OSError as err:
221
+ logging.debug("Error connecting to %s:%s - %s", host_str, port, err)
222
+ return None
223
+ finally:
224
+ try:
225
+ writer.close()
226
+ await writer.wait_closed()
227
+ except (NameError, OSError):
228
+ pass
229
+
230
+ # Route to service-specific validation
231
+ match service_name.lower():
232
+ case "http":
233
+ if is_http_response(b):
234
+ if svc_txt := get_http_service(b):
235
+ return host, svc_txt
236
+ case "ssh":
237
+ # SSH service: validate "SSH-" banner
238
+ if svc_txt := get_service(b, "ssh"):
239
+ return host, svc_txt
240
+ case _:
241
+ # Generic service: accept any response
242
+ if b and len(b) > 0:
243
+ try:
244
+ svc_txt = b.decode("utf-8", errors="ignore").splitlines()[0].strip()
245
+ if svc_txt:
246
+ return host, svc_txt
247
+ except Exception:
248
+ pass
249
+
250
+ return None
@@ -0,0 +1,60 @@
1
+ """
2
+ HTTP server detection utilities.
3
+
4
+ Provides functions to identify and validate HTTP service responses.
5
+ """
6
+
7
+ import logging
8
+
9
+
10
+ def is_http_response(data: bytes) -> bool:
11
+ """
12
+ Check if response data contains an HTTP protocol indicator.
13
+
14
+ Validates that the response starts with "HTTP/" which indicates
15
+ a valid HTTP response (e.g., "HTTP/1.1 200 OK", "HTTP/1.0", etc.)
16
+
17
+ Args:
18
+ data: Raw bytes received from the server
19
+
20
+ Returns:
21
+ True if data appears to be an HTTP response, False otherwise
22
+ """
23
+ if not data:
24
+ return False
25
+
26
+ try:
27
+ # Try to decode the first line
28
+ lines = data.splitlines()
29
+ if not lines:
30
+ return False
31
+
32
+ first_line = lines[0].decode("utf-8", errors="ignore").strip()
33
+ return first_line.startswith("HTTP/")
34
+ except Exception as err:
35
+ logging.debug("Error checking HTTP response: %s", err)
36
+ return False
37
+
38
+
39
+ def get_http_service(b: bytes) -> str | None:
40
+ """
41
+ Extract HTTP service banner/response from bytes.
42
+
43
+ Returns the first line of the HTTP response, which typically contains
44
+ the protocol version and status code (e.g., "HTTP/1.1 200 OK").
45
+
46
+ Args:
47
+ b: Raw bytes received from HTTP server
48
+
49
+ Returns:
50
+ First line of HTTP response if valid, None otherwise
51
+ """
52
+ if lines := b.splitlines():
53
+ try:
54
+ svc_txt = lines[0].decode("utf-8", errors="ignore").strip()
55
+ if svc_txt.startswith("HTTP/"):
56
+ return svc_txt
57
+ except Exception:
58
+ pass
59
+
60
+ return None
@@ -0,0 +1,84 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ import asyncio
4
+ import socket
5
+ from http.server import HTTPServer, BaseHTTPRequestHandler
6
+ import threading
7
+
8
+
9
+ @pytest.fixture
10
+ def http_server_port():
11
+ """Get an available port for test server."""
12
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
13
+ s.bind(("127.0.0.1", 0))
14
+ return s.getsockname()[1]
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_http_server(http_server_port):
19
+ """Start a simple HTTP server for testing."""
20
+
21
+ class SimpleHTTPHandler(BaseHTTPRequestHandler):
22
+ def do_GET(self):
23
+ self.send_response(200)
24
+ self.send_header("Content-Length", "13")
25
+ self.end_headers()
26
+ self.wfile.write(b"Test response")
27
+
28
+ def log_message(self, format, *args):
29
+ pass # Suppress logging
30
+
31
+ server = HTTPServer(("127.0.0.1", http_server_port), SimpleHTTPHandler)
32
+ thread = threading.Thread(target=server.serve_forever)
33
+ thread.daemon = True
34
+ thread.start()
35
+
36
+ yield http_server_port
37
+
38
+ server.shutdown()
39
+
40
+
41
+ @pytest_asyncio.fixture
42
+ async def mock_http_server_async(http_server_port):
43
+ """Async mock HTTP server using asyncio (alternative implementation)."""
44
+
45
+ async def handle_http(reader, writer):
46
+ """Handle HTTP connection by sending a response."""
47
+ # Read the request (we don't need to parse it, just consume it)
48
+ try:
49
+ await reader.readuntil(b"\r\n\r\n")
50
+ except asyncio.IncompleteReadError:
51
+ pass
52
+ except Exception:
53
+ pass
54
+
55
+ # Send HTTP response
56
+ response = b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nTest response"
57
+ writer.write(response)
58
+ await writer.drain()
59
+ writer.close()
60
+ try:
61
+ await writer.wait_closed()
62
+ except Exception:
63
+ pass
64
+
65
+ server = await asyncio.start_server(handle_http, "127.0.0.1", http_server_port)
66
+
67
+ async def serve():
68
+ async with server:
69
+ await server.serve_forever()
70
+
71
+ task = asyncio.create_task(serve())
72
+
73
+ yield http_server_port
74
+
75
+ task.cancel()
76
+ try:
77
+ await task
78
+ except asyncio.CancelledError:
79
+ pass
80
+ server.close()
81
+ try:
82
+ await server.wait_closed()
83
+ except Exception:
84
+ pass
@@ -0,0 +1,134 @@
1
+ import pytest
2
+ import ipaddress
3
+ import asyncio
4
+ import findssh
5
+ import findssh.coro
6
+
7
+ TIMEOUT = 0.5
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_discover_services_validation():
12
+ """Test discover_services parameter validation."""
13
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
14
+
15
+ # Test max_concurrent validation
16
+ with pytest.raises(ValueError):
17
+ await findssh.coro.discover_services(
18
+ net, {"ssh": 22}, TIMEOUT, max_concurrent=0
19
+ )
20
+
21
+ # Test invalid port type
22
+ with pytest.raises(TypeError):
23
+ await findssh.coro.discover_services(
24
+ net,
25
+ {"ssh": "invalid"},
26
+ TIMEOUT, # type: ignore
27
+ )
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_discover_services_port_list():
32
+ """Test discover_services with port as list."""
33
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
34
+
35
+ # Test with list of ports
36
+ results = await findssh.coro.discover_services(
37
+ net, {"http": [80, 443]}, TIMEOUT, max_concurrent=10
38
+ )
39
+
40
+ assert isinstance(results, dict)
41
+ assert "http" in results
42
+ assert isinstance(results["http"], list)
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_discover_services_http_detection(mock_http_server):
47
+ """Test HTTP service detection with working mock HTTP server."""
48
+ port = mock_http_server
49
+
50
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
51
+ results = await findssh.coro.discover_services(
52
+ net, {"http": port}, timeout=1.0, max_concurrent=10
53
+ )
54
+
55
+ assert isinstance(results, dict)
56
+ assert "http" in results
57
+ assert isinstance(results["http"], list)
58
+
59
+ # With the fixed HTTP detection (sending GET request), we should find the server
60
+ assert len(results["http"]) > 0, "HTTP server should be detected"
61
+
62
+ # Verify the response format
63
+ host, banner = results["http"][0]
64
+ assert isinstance(host, ipaddress.IPv4Address)
65
+ assert isinstance(banner, str)
66
+ assert "HTTP" in banner
67
+ assert "200" in banner
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_discover_services_http_detection_async(mock_http_server_async):
72
+ """Test HTTP service detection with async mock HTTP server."""
73
+ port = mock_http_server_async
74
+
75
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
76
+ results = await findssh.coro.discover_services(
77
+ net, {"http": port}, timeout=1.0, max_concurrent=10
78
+ )
79
+
80
+ assert isinstance(results, dict)
81
+ assert "http" in results
82
+ assert isinstance(results["http"], list)
83
+
84
+ # Should detect the async HTTP server
85
+ assert len(results["http"]) > 0, "Async HTTP server should be detected"
86
+
87
+ host, banner = results["http"][0]
88
+ assert isinstance(host, ipaddress.IPv4Address)
89
+ assert "HTTP" in banner
90
+
91
+
92
+ def test_discover_services_multiple_services():
93
+ """Test discover_services returns dict structure for multiple services."""
94
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
95
+
96
+ results = asyncio.run(
97
+ findssh.coro.discover_services(
98
+ net, {"ssh": 22, "http": 80}, TIMEOUT, max_concurrent=10
99
+ )
100
+ )
101
+
102
+ # Verify structure
103
+ assert isinstance(results, dict)
104
+ assert set(results.keys()) == {"ssh", "http"}
105
+ assert isinstance(results["ssh"], list)
106
+ assert isinstance(results["http"], list)
107
+
108
+ # Verify each result in lists has correct structure
109
+ for service_name, hosts in results.items():
110
+ for host, banner in hosts:
111
+ assert isinstance(host, ipaddress.IPv4Address)
112
+ assert isinstance(banner, str)
113
+
114
+
115
+ def test_discover_services_structure():
116
+ """Test that discover_services returns correct structure for empty results."""
117
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
118
+
119
+ # Test multiple services on ports that likely have nothing
120
+ results = asyncio.run(
121
+ findssh.coro.discover_services(
122
+ net,
123
+ {"service1": 9999, "service2": 9998, "service3": 9997},
124
+ timeout=0.1,
125
+ max_concurrent=10,
126
+ )
127
+ )
128
+
129
+ # Verify structure even with empty results
130
+ assert isinstance(results, dict)
131
+ assert "service1" in results
132
+ assert "service2" in results
133
+ assert "service3" in results
134
+ assert all(isinstance(v, list) for v in results.values())
@@ -1,5 +1,6 @@
1
1
  import ipaddress
2
2
  import asyncio
3
+ import pytest
3
4
 
4
5
  import findssh
5
6
  import findssh.coro
@@ -15,3 +16,9 @@ def test_coroutine():
15
16
  assert isinstance(host, ipaddress.IPv4Address)
16
17
  assert isinstance(svc, str)
17
18
  break
19
+
20
+
21
+ def test_coroutine_max_concurrent_validation():
22
+ net = ipaddress.ip_network("127.0.0.1/32", strict=False)
23
+ with pytest.raises(ValueError):
24
+ asyncio.run(findssh.coro.get_hosts(net, PORT, TIMEOUT, max_concurrent=0))
@@ -1,6 +1,7 @@
1
1
  """
2
2
  normally we use coroutines, but for demo purposes we have threadpool too.
3
3
  """
4
+
4
5
  import ipaddress
5
6
 
6
7
  import findssh.threadpool
@@ -4,12 +4,12 @@ significantly slower and uses more resources
4
4
  than the recommended asyncio coroutines in coro.py
5
5
  """
6
6
 
7
- from __future__ import annotations
8
7
  import concurrent.futures
8
+ import logging
9
9
  import ipaddress as ip
10
- import typing as T
10
+ from collections.abc import Iterable
11
11
 
12
- from .base import is_port_open
12
+ from .base import HostResult, is_port_open
13
13
 
14
14
  __all__ = ["get_hosts"]
15
15
 
@@ -19,7 +19,7 @@ def get_hosts(
19
19
  port: int,
20
20
  timeout: float,
21
21
  service: str | None = None,
22
- ) -> T.Iterable[tuple[ip.IPv4Address, str]]:
22
+ ) -> Iterable[HostResult]:
23
23
  """
24
24
  loops over hosts in network, one thread per address.
25
25
  This is MUCH slower than asyncio coroutines in coro.py
@@ -37,5 +37,7 @@ def get_hosts(
37
37
  if res := future.result():
38
38
  yield res
39
39
  except KeyboardInterrupt:
40
- print("KeyboardInterrupt")
40
+ logging.info("KeyboardInterrupt")
41
+ # the shutdown() is necessary to immediately halt threads, otherwise the
42
+ # futures are still waited on
41
43
  exc.shutdown(wait=True, cancel_futures=True)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: findssh
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: find open servers on IPv4 subnet such as SSH
5
5
  Keywords: port-scan,ssh
6
6
  Classifier: Development Status :: 5 - Production/Stable
@@ -18,29 +18,28 @@ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Topic :: Security
19
19
  Classifier: Topic :: System :: Networking
20
20
  Classifier: Topic :: Utilities
21
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE.txt
24
24
  Provides-Extra: tests
25
25
  Requires-Dist: pytest; extra == "tests"
26
- Provides-Extra: lint
27
- Requires-Dist: flake8; extra == "lint"
28
- Requires-Dist: flake8-bugbear; extra == "lint"
29
- Requires-Dist: flake8-builtins; extra == "lint"
30
- Requires-Dist: flake8-blind-except; extra == "lint"
31
- Requires-Dist: mypy; extra == "lint"
26
+ Requires-Dist: pytest-asyncio; extra == "tests"
27
+ Requires-Dist: mypy; extra == "tests"
28
+ Dynamic: license-file
32
29
 
33
30
  # Find SSH servers (without NMAP)
34
31
 
35
32
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3336467.svg)](https://doi.org/10.5281/zenodo.3336467)
36
- ![ci](https://github.com/scivision/findssh/workflows/ci/badge.svg)
37
- [![PyPI Download stats](http://pepy.tech/badge/findssh)](http://pepy.tech/project/findssh)
33
+ [![ci](https://github.com/scivision/findssh/actions/workflows/ci.yml/badge.svg)](https://github.com/scivision/findssh/actions/workflows/ci.yml)
34
+ [![PyPI Download stats](https://pepy.tech/badge/findssh)](https://pepy.tech/project/findssh)
38
35
 
39
36
  Platform-independently find SSH servers (or other services with open ports) on an IPv4 subnet in pure Python WITHOUT NMAP.
40
37
  Scan entire IPv4 subnet in less than 1 second using Python standard library `asyncio` coroutines and a single thread.
41
38
 
42
- The default
43
- [asyncio coroutine](https://docs.python.org/3/library/asyncio.html)
39
+ The findssh
40
+ [asyncio](https://docs.python.org/3/library/asyncio.html)
41
+ coroutine
42
+ [TaskGroup](https://docs.python.org/3/library/asyncio-task.html#task-groups)
44
43
  uses a single thread and is more than 10x faster than
45
44
  [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html).
46
45
 
@@ -55,7 +54,7 @@ or from this repo:
55
54
  ```sh
56
55
  git clone https://github.com/scivision/findssh
57
56
 
58
- pip install -e findssh
57
+ pip install -e ./findssh
59
58
  ```
60
59
 
61
60
  ## Usage
@@ -76,6 +75,12 @@ or use project script e.g. from [pipx](https://github.com/pypa/pipx):
76
75
  findssh
77
76
  ```
78
77
 
78
+ For example, to scan your local subnet for SSH servers:
79
+
80
+ ```sh
81
+ findssh -p 22 192.168.1.0
82
+ ```
83
+
79
84
  * `-s` check the string from the server to attempt to verify the correct service has been found
80
85
  * `-t` timeout per server (seconds) useful for high latency connection
81
86
  * `-b` baseip (check other subnet besides your own)
@@ -5,6 +5,7 @@ src/findssh/__init__.py
5
5
  src/findssh/__main__.py
6
6
  src/findssh/base.py
7
7
  src/findssh/coro.py
8
+ src/findssh/http.py
8
9
  src/findssh/threadpool.py
9
10
  src/findssh.egg-info/PKG-INFO
10
11
  src/findssh.egg-info/SOURCES.txt
@@ -13,5 +14,7 @@ src/findssh.egg-info/entry_points.txt
13
14
  src/findssh.egg-info/requires.txt
14
15
  src/findssh.egg-info/top_level.txt
15
16
  src/findssh/tests/__init__.py
16
- src/findssh/tests/test_coro.py
17
- src/findssh/tests/test_threadpool.py
17
+ src/findssh/tests/conftest.py
18
+ src/findssh/tests/test_discovery.py
19
+ src/findssh/tests/test_ssh_coroutine.py
20
+ src/findssh/tests/test_ssh_threadpool.py
@@ -0,0 +1,5 @@
1
+
2
+ [tests]
3
+ pytest
4
+ pytest-asyncio
5
+ mypy
@@ -1,34 +0,0 @@
1
- from __future__ import annotations
2
- import socket
3
- import ipaddress
4
-
5
- from .coro import get_hosts
6
-
7
- __all__ = ["get_hosts", "address2net", "get_lan_ip"]
8
-
9
- __version__ = "2.0.1"
10
-
11
-
12
- def get_lan_ip() -> ipaddress.IPv4Address | ipaddress.IPv6Address:
13
- """
14
- get IP address of currently used LAN interface
15
- ref: http://stackoverflow.com/a/23822431
16
- """
17
-
18
- name = socket.gethostname()
19
- host = socket.gethostbyname(name)
20
- return ipaddress.ip_address(host)
21
-
22
-
23
- def address2net(
24
- addr: ipaddress.IPv4Address, mask: str = "24"
25
- ) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
26
-
27
- if isinstance(addr, ipaddress.IPv4Address):
28
- net = ipaddress.ip_network(addr.exploded.rsplit(".", 1)[0] + f".0/{mask}")
29
- elif isinstance(addr, ipaddress.IPv6Address):
30
- net = ipaddress.ip_network(addr.exploded.rsplit(":", 1)[0] + f":0/{mask}")
31
- else:
32
- raise TypeError(addr)
33
-
34
- return net
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- scans IPv4 subnet for SSH servers on Port 22 or other server ports.
4
- Useful for machines that don't/can't have NMAP installed (e.g. Windows),
5
- and device does not have Avahi server.
6
- I wanted to make it as cross-platform as possible,
7
- where the user would have only basic Python installed (Windows)
8
-
9
- Note:
10
- timeout value bare minimum is 0.15 seconds for LAN,
11
- suggest using higher values say 0.25 or 0.35 to allow for network / CPU delays
12
- Wifi timeout should be 1 second or more
13
- """
14
-
15
- import asyncio
16
- import logging
17
- import ipaddress as ip
18
- from argparse import ArgumentParser
19
-
20
- from . import get_lan_ip, address2net
21
- from . import coro
22
- from . import threadpool
23
-
24
- PORT = 22
25
- TIMEOUT = 1.0
26
-
27
-
28
- def main():
29
- p = ArgumentParser("scan for hosts with open port, without NMAP")
30
- p.add_argument("-p", "--port", help="single port to try", default=PORT, type=int)
31
- p.add_argument(
32
- "-s", "--service", default="", help="string to match to qualify detections"
33
- )
34
- p.add_argument(
35
- "-t",
36
- "--timeout",
37
- help="timeout to wait for server. Must be finite or will hang.",
38
- type=float,
39
- default=TIMEOUT,
40
- )
41
- p.add_argument("-b", "--baseip", help="set a specific subnet to scan")
42
- p.add_argument("-v", "--verbose", action="store_true")
43
- p.add_argument(
44
- "-threadpool", help="use threadpool instead of asyncio", action="store_true"
45
- )
46
- P = p.parse_args()
47
-
48
- if P.verbose:
49
- logging.basicConfig(level=logging.DEBUG)
50
-
51
- if not P.baseip:
52
- ownip = get_lan_ip()
53
- print("own address", ownip)
54
- else:
55
- ownip = ip.ip_address(P.baseip)
56
-
57
- net = address2net(ownip)
58
- print("searching", net)
59
-
60
- if P.threadpool:
61
- for host in threadpool.get_hosts(
62
- net,
63
- P.port,
64
- P.timeout,
65
- P.service,
66
- ):
67
- print(host)
68
- else:
69
- asyncio.run(coro.get_hosts(net, P.port, P.timeout, P.service))
70
-
71
-
72
- if __name__ == "__main__":
73
- main()
@@ -1,67 +0,0 @@
1
- """
2
- this method uses Python asyncio coroutines and is significantly faster and leaner,
3
- here using only ONE thread total, instead of the slower one-thread-per-address
4
- threadpool.py method
5
- """
6
-
7
- from __future__ import annotations
8
- import logging
9
- import ipaddress as ip
10
- import asyncio
11
-
12
- from .base import get_service
13
-
14
- __all__ = ["get_hosts"]
15
-
16
-
17
- async def get_hosts(
18
- net: ip.IPv4Network,
19
- port: int,
20
- timeout: float,
21
- service: str | None = None,
22
- ) -> list[tuple[ip.IPv4Address, str]]:
23
- """
24
- Timeout must be finite otherwise non-existant hosts are waited for forever
25
- """
26
-
27
- hosts = []
28
- futures = [waiter(host, port, service, timeout) for host in net.hosts()]
29
- for h in asyncio.as_completed(futures):
30
- if host := await h:
31
- print(host)
32
- hosts.append(host)
33
-
34
- return hosts
35
-
36
-
37
- async def waiter(
38
- host: ip.IPv4Address, port: int, service: str, timeout: float
39
- ) -> tuple[ip.IPv4Address, str]:
40
- try:
41
- res = await asyncio.wait_for(is_port_open(host, port, service), timeout=timeout)
42
- except asyncio.TimeoutError:
43
- res = None
44
-
45
- return res
46
-
47
-
48
- async def is_port_open(
49
- host: ip.IPv4Address, port: int, service: str
50
- ) -> tuple[ip.IPv4Address, str]:
51
- """
52
- https://docs.python.org/3/library/asyncio-stream.html#asyncio.open_connection
53
- """
54
- host_str = host.exploded
55
-
56
- try:
57
- reader, _ = await asyncio.open_connection(host_str, port)
58
- if not (b := await reader.read(32)):
59
- return None
60
- except OSError as err: # to avoid flake8 error OSError has ConnectionError
61
- logging.debug(err)
62
- return None
63
-
64
- if svc_txt := get_service(b, service):
65
- return host, svc_txt
66
-
67
- return None
@@ -1,10 +0,0 @@
1
-
2
- [lint]
3
- flake8
4
- flake8-bugbear
5
- flake8-builtins
6
- flake8-blind-except
7
- mypy
8
-
9
- [tests]
10
- pytest
File without changes
File without changes