findssh 2.0.0__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.0/src/findssh.egg-info → findssh-2.1.0}/PKG-INFO +35 -10
  2. {findssh-2.0.0 → findssh-2.1.0}/README.md +27 -5
  3. {findssh-2.0.0 → findssh-2.1.0}/pyproject.toml +10 -10
  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.0 → 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.0/src/findssh/tests/test_coro.py → findssh-2.1.0/src/findssh/tests/test_ssh_coroutine.py +7 -0
  12. findssh-2.0.0/src/findssh/tests/test_threadpool.py → findssh-2.1.0/src/findssh/tests/test_ssh_threadpool.py +1 -0
  13. {findssh-2.0.0 → findssh-2.1.0}/src/findssh/threadpool.py +7 -5
  14. {findssh-2.0.0 → findssh-2.1.0/src/findssh.egg-info}/PKG-INFO +35 -10
  15. {findssh-2.0.0 → findssh-2.1.0}/src/findssh.egg-info/SOURCES.txt +6 -2
  16. findssh-2.1.0/src/findssh.egg-info/entry_points.txt +2 -0
  17. findssh-2.1.0/src/findssh.egg-info/requires.txt +5 -0
  18. findssh-2.0.0/src/findssh/__init__.py +0 -30
  19. findssh-2.0.0/src/findssh/__main__.py +0 -73
  20. findssh-2.0.0/src/findssh/coro.py +0 -68
  21. findssh-2.0.0/src/findssh.egg-info/requires.txt +0 -10
  22. {findssh-2.0.0 → findssh-2.1.0}/LICENSE.txt +0 -0
  23. {findssh-2.0.0 → findssh-2.1.0}/setup.cfg +0 -0
  24. {findssh-2.0.0 → findssh-2.1.0}/src/findssh/tests/__init__.py +0 -0
  25. {findssh-2.0.0 → findssh-2.1.0}/src/findssh.egg-info/dependency_links.txt +0 -0
  26. {findssh-2.0.0 → 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.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,23 +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
- Provides-Extra: tests
24
- Provides-Extra: lint
25
23
  License-File: LICENSE.txt
24
+ Provides-Extra: tests
25
+ Requires-Dist: pytest; extra == "tests"
26
+ Requires-Dist: pytest-asyncio; extra == "tests"
27
+ Requires-Dist: mypy; extra == "tests"
28
+ Dynamic: license-file
26
29
 
27
30
  # Find SSH servers (without NMAP)
28
31
 
29
32
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3336467.svg)](https://doi.org/10.5281/zenodo.3336467)
30
- ![ci](https://github.com/scivision/findssh/workflows/ci/badge.svg)
31
- [![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)
32
35
 
33
36
  Platform-independently find SSH servers (or other services with open ports) on an IPv4 subnet in pure Python WITHOUT NMAP.
34
37
  Scan entire IPv4 subnet in less than 1 second using Python standard library `asyncio` coroutines and a single thread.
35
38
 
36
- The default
37
- [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)
38
43
  uses a single thread and is more than 10x faster than
39
44
  [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html).
40
45
 
@@ -49,7 +54,7 @@ or from this repo:
49
54
  ```sh
50
55
  git clone https://github.com/scivision/findssh
51
56
 
52
- pip install -e findssh
57
+ pip install -e ./findssh
53
58
  ```
54
59
 
55
60
  ## Usage
@@ -64,6 +69,18 @@ from command line:
64
69
  python -m findssh
65
70
  ```
66
71
 
72
+ or use project script e.g. from [pipx](https://github.com/pypa/pipx):
73
+
74
+ ```sh
75
+ findssh
76
+ ```
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
+
67
84
  * `-s` check the string from the server to attempt to verify the correct service has been found
68
85
  * `-t` timeout per server (seconds) useful for high latency connection
69
86
  * `-b` baseip (check other subnet besides your own)
@@ -88,3 +105,11 @@ Thread pool (100 thread max, slow, heavy):
88
105
 
89
106
  1.39 s ± 213 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
90
107
  ```
108
+
109
+ Trying to open too many threads via ThreadPoolExecutor can cause a system error like
110
+
111
+ ```
112
+ OSError: [Errno 24] Too many open files
113
+ ```
114
+
115
+ Thus in practical terms, using coroutines can be significantly faster than threads while using less system resources.
@@ -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
@@ -38,6 +40,18 @@ from command line:
38
40
  python -m findssh
39
41
  ```
40
42
 
43
+ or use project script e.g. from [pipx](https://github.com/pypa/pipx):
44
+
45
+ ```sh
46
+ findssh
47
+ ```
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
+
41
55
  * `-s` check the string from the server to attempt to verify the correct service has been found
42
56
  * `-t` timeout per server (seconds) useful for high latency connection
43
57
  * `-b` baseip (check other subnet besides your own)
@@ -62,3 +76,11 @@ Thread pool (100 thread max, slow, heavy):
62
76
 
63
77
  1.39 s ± 213 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
64
78
  ```
79
+
80
+ Trying to open too many threads via ThreadPoolExecutor can cause a system error like
81
+
82
+ ```
83
+ OSError: [Errno 24] Too many open files
84
+ ```
85
+
86
+ Thus in practical terms, using coroutines can be significantly faster than threads while using less system resources.
@@ -4,7 +4,6 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "findssh"
7
- version = "2.0.0"
8
7
  description = "find open servers on IPv4 subnet such as SSH"
9
8
  keywords = ["port-scan", "ssh"]
10
9
  classifiers = [
@@ -24,22 +23,23 @@ classifiers = [
24
23
  "Topic :: System :: Networking",
25
24
  "Topic :: Utilities"
26
25
  ]
27
- dynamic = ["readme"]
28
- requires-python = ">=3.9"
26
+ dynamic = ["readme", "version"]
27
+ requires-python = ">=3.11"
28
+
29
+ [project.scripts]
30
+ findssh = "findssh.__main__:main"
29
31
 
30
32
  [project.optional-dependencies]
31
- tests = ["pytest"]
32
- lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy"]
33
+ tests = ["pytest", "pytest-asyncio", "mypy"]
33
34
 
34
35
  [tool.setuptools.dynamic]
35
36
  readme = {file = ["README.md"], content-type = "text/markdown"}
37
+ version = {attr = "findssh.__version__"}
36
38
 
37
39
  [tool.black]
38
40
  line-length = 90
39
41
 
40
42
  [tool.mypy]
41
- files = ["src"]
42
- strict_optional = false
43
- allow_redefinition = true
44
- show_error_context = false
45
- 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():