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.
- {findssh-2.0.1/src/findssh.egg-info → findssh-2.1.0}/PKG-INFO +19 -14
- {findssh-2.0.1 → findssh-2.1.0}/README.md +13 -5
- {findssh-2.0.1 → findssh-2.1.0}/pyproject.toml +5 -8
- findssh-2.1.0/src/findssh/__init__.py +31 -0
- findssh-2.1.0/src/findssh/__main__.py +163 -0
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh/base.py +22 -12
- findssh-2.1.0/src/findssh/coro.py +250 -0
- findssh-2.1.0/src/findssh/http.py +60 -0
- findssh-2.1.0/src/findssh/tests/conftest.py +84 -0
- findssh-2.1.0/src/findssh/tests/test_discovery.py +134 -0
- findssh-2.0.1/src/findssh/tests/test_coro.py → findssh-2.1.0/src/findssh/tests/test_ssh_coroutine.py +7 -0
- findssh-2.0.1/src/findssh/tests/test_threadpool.py → findssh-2.1.0/src/findssh/tests/test_ssh_threadpool.py +1 -0
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh/threadpool.py +7 -5
- {findssh-2.0.1 → findssh-2.1.0/src/findssh.egg-info}/PKG-INFO +19 -14
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/SOURCES.txt +5 -2
- findssh-2.1.0/src/findssh.egg-info/requires.txt +5 -0
- findssh-2.0.1/src/findssh/__init__.py +0 -34
- findssh-2.0.1/src/findssh/__main__.py +0 -73
- findssh-2.0.1/src/findssh/coro.py +0 -67
- findssh-2.0.1/src/findssh.egg-info/requires.txt +0 -10
- {findssh-2.0.1 → findssh-2.1.0}/LICENSE.txt +0 -0
- {findssh-2.0.1 → findssh-2.1.0}/setup.cfg +0 -0
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh/tests/__init__.py +0 -0
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/dependency_links.txt +0 -0
- {findssh-2.0.1 → findssh-2.1.0}/src/findssh.egg-info/entry_points.txt +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: findssh
|
|
3
|
-
Version: 2.0
|
|
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.
|
|
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
|
-
|
|
27
|
-
Requires-Dist:
|
|
28
|
-
|
|
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
|
[](https://doi.org/10.5281/zenodo.3336467)
|
|
36
|
-

|
|
37
|
-
[](https://github.com/scivision/findssh/actions/workflows/ci.yml)
|
|
34
|
+
[](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
|
|
43
|
-
[asyncio
|
|
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
|
[](https://doi.org/10.5281/zenodo.3336467)
|
|
4
|
-

|
|
5
|
-
[](https://github.com/scivision/findssh/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
|
11
|
-
[asyncio
|
|
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.
|
|
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 = ["
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
3
|
+
import logging
|
|
4
|
+
import collections.abc
|
|
5
5
|
|
|
6
|
+
HostResult = tuple[ip.IPv4Address, str]
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
) ->
|
|
60
|
+
) -> collections.abc.Iterable[HostResult]:
|
|
53
61
|
"""
|
|
54
|
-
|
|
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())
|
findssh-2.0.1/src/findssh/tests/test_coro.py → findssh-2.1.0/src/findssh/tests/test_ssh_coroutine.py
RENAMED
|
@@ -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))
|
|
@@ -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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: findssh
|
|
3
|
-
Version: 2.0
|
|
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.
|
|
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
|
-
|
|
27
|
-
Requires-Dist:
|
|
28
|
-
|
|
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
|
[](https://doi.org/10.5281/zenodo.3336467)
|
|
36
|
-

|
|
37
|
-
[](https://github.com/scivision/findssh/actions/workflows/ci.yml)
|
|
34
|
+
[](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
|
|
43
|
-
[asyncio
|
|
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/
|
|
17
|
-
src/findssh/tests/
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|