netforge-network-toolkit 0.1.0__py3-none-any.whl
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.
- netforge/__init__.py +19 -0
- netforge/diagnostics.py +180 -0
- netforge/http.py +237 -0
- netforge/network.py +100 -0
- netforge/ports.py +152 -0
- netforge/servers.py +336 -0
- netforge/sockets.py +197 -0
- netforge/system.py +26 -0
- netforge_network_toolkit-0.1.0.dist-info/METADATA +28 -0
- netforge_network_toolkit-0.1.0.dist-info/RECORD +12 -0
- netforge_network_toolkit-0.1.0.dist-info/WHEEL +5 -0
- netforge_network_toolkit-0.1.0.dist-info/top_level.txt +1 -0
netforge/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from . import system
|
|
2
|
+
from . import network
|
|
3
|
+
from . import sockets
|
|
4
|
+
from . import http
|
|
5
|
+
from . import ports
|
|
6
|
+
from . import servers
|
|
7
|
+
from . import diagnostics
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"system",
|
|
13
|
+
"network",
|
|
14
|
+
"sockets",
|
|
15
|
+
"http",
|
|
16
|
+
"ports",
|
|
17
|
+
"servers",
|
|
18
|
+
"diagnostics",
|
|
19
|
+
]
|
netforge/diagnostics.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
NetForge - Network Diagnostics
|
|
4
|
+
|
|
5
|
+
DNS resolution, TCP connectivity checks and port diagnostics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from . import ports
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DNSInfo:
|
|
18
|
+
hostname: str
|
|
19
|
+
ipv4: tuple[str, ...]
|
|
20
|
+
ipv6: tuple[str, ...]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ConnectivityResult:
|
|
25
|
+
host: str
|
|
26
|
+
port: int
|
|
27
|
+
reachable: bool
|
|
28
|
+
latency_ms: float | None = None
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DiagnosticReport:
|
|
34
|
+
host: str
|
|
35
|
+
dns: DNSInfo | None
|
|
36
|
+
connectivity: ConnectivityResult
|
|
37
|
+
ports: tuple[ports.PortResult, ...]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def reachable(self) -> bool:
|
|
41
|
+
return self.connectivity.reachable
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve(host: str) -> DNSInfo:
|
|
45
|
+
"""
|
|
46
|
+
Resolve a hostname into IPv4 and IPv6 addresses.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if not isinstance(host, str) or not host.strip():
|
|
50
|
+
raise ValueError("Host must be a non-empty string")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
records = socket.getaddrinfo(
|
|
54
|
+
host,
|
|
55
|
+
None,
|
|
56
|
+
family=socket.AF_UNSPEC,
|
|
57
|
+
type=socket.SOCK_STREAM,
|
|
58
|
+
)
|
|
59
|
+
except socket.gaierror as error:
|
|
60
|
+
raise OSError(f"DNS resolution failed for {host}: {error}") from error
|
|
61
|
+
|
|
62
|
+
ipv4 = set()
|
|
63
|
+
ipv6 = set()
|
|
64
|
+
|
|
65
|
+
for family, _, _, _, address in records:
|
|
66
|
+
if family == socket.AF_INET:
|
|
67
|
+
ipv4.add(address[0])
|
|
68
|
+
elif family == socket.AF_INET6:
|
|
69
|
+
ipv6.add(address[0])
|
|
70
|
+
|
|
71
|
+
return DNSInfo(
|
|
72
|
+
hostname=host,
|
|
73
|
+
ipv4=tuple(sorted(ipv4)),
|
|
74
|
+
ipv6=tuple(sorted(ipv6)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_connectivity(
|
|
79
|
+
host: str,
|
|
80
|
+
port: int = 443,
|
|
81
|
+
timeout: float = 3.0,
|
|
82
|
+
) -> ConnectivityResult:
|
|
83
|
+
"""
|
|
84
|
+
Test TCP connectivity to a host and port.
|
|
85
|
+
|
|
86
|
+
This is a TCP connection test, not an ICMP ping.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
if not isinstance(host, str) or not host.strip():
|
|
90
|
+
raise ValueError("Host must be a non-empty string")
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
isinstance(port, bool)
|
|
94
|
+
or not isinstance(port, int)
|
|
95
|
+
or not 1 <= port <= 65535
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("Port must be between 1 and 65535")
|
|
98
|
+
|
|
99
|
+
if timeout <= 0:
|
|
100
|
+
raise ValueError("Timeout must be greater than zero")
|
|
101
|
+
|
|
102
|
+
start = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with socket.create_connection(
|
|
106
|
+
(host, port),
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
):
|
|
109
|
+
latency = (time.perf_counter() - start) * 1000
|
|
110
|
+
|
|
111
|
+
return ConnectivityResult(
|
|
112
|
+
host=host,
|
|
113
|
+
port=port,
|
|
114
|
+
reachable=True,
|
|
115
|
+
latency_ms=round(latency, 2),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
except OSError as error:
|
|
119
|
+
return ConnectivityResult(
|
|
120
|
+
host=host,
|
|
121
|
+
port=port,
|
|
122
|
+
reachable=False,
|
|
123
|
+
error=str(error),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def inspect(
|
|
128
|
+
host: str,
|
|
129
|
+
scan_ports: tuple[int, ...] | list[int] = (80, 443),
|
|
130
|
+
connectivity_port: int = 443,
|
|
131
|
+
timeout: float = 3.0,
|
|
132
|
+
max_workers: int = 10,
|
|
133
|
+
) -> DiagnosticReport:
|
|
134
|
+
"""
|
|
135
|
+
Generate a network diagnostic report for a host.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
host: Hostname or IP address.
|
|
139
|
+
scan_ports: TCP ports to inspect.
|
|
140
|
+
connectivity_port: Port used for the connectivity test.
|
|
141
|
+
timeout: Timeout for each network operation.
|
|
142
|
+
max_workers: Maximum concurrent port checks.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A DiagnosticReport containing DNS, connectivity and port results.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if not isinstance(host, str) or not host.strip():
|
|
149
|
+
raise ValueError("Host must be a non-empty string")
|
|
150
|
+
|
|
151
|
+
if timeout <= 0:
|
|
152
|
+
raise ValueError("Timeout must be greater than zero")
|
|
153
|
+
|
|
154
|
+
dns_info = None
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
dns_info = resolve(host)
|
|
158
|
+
except OSError:
|
|
159
|
+
# Connectivity and port checks may still work with an IP address.
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
connectivity = check_connectivity(
|
|
163
|
+
host,
|
|
164
|
+
port=connectivity_port,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
port_results = ports.scan_ports(
|
|
169
|
+
host,
|
|
170
|
+
scan_ports,
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
max_workers=max_workers,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return DiagnosticReport(
|
|
176
|
+
host=host,
|
|
177
|
+
dns=dns_info,
|
|
178
|
+
connectivity=connectivity,
|
|
179
|
+
ports=tuple(port_results),
|
|
180
|
+
)
|
netforge/http.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
|
|
2
|
+
import json as json_module
|
|
3
|
+
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, Any
|
|
10
|
+
import ssl
|
|
11
|
+
import warnings
|
|
12
|
+
import certifi
|
|
13
|
+
|
|
14
|
+
class HTTPError(Exception):
|
|
15
|
+
"""Raised when an HTTP response has an unsuccessful status."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, url: str, body: str = ""):
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.url = url
|
|
20
|
+
self.body = body
|
|
21
|
+
|
|
22
|
+
super().__init__(
|
|
23
|
+
f"HTTP {status_code} returned by {url}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NetworkError(Exception):
|
|
28
|
+
"""Raised when an HTTP request cannot reach its destination."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class HTTPResponse:
|
|
33
|
+
"""Represents an HTTP response."""
|
|
34
|
+
|
|
35
|
+
status_code: int
|
|
36
|
+
url: str
|
|
37
|
+
headers: dict[str, str]
|
|
38
|
+
content: bytes
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ok(self) -> bool:
|
|
42
|
+
return 200 <= self.status_code < 400
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def text(self) -> str:
|
|
46
|
+
content_type = self.headers.get("Content-Type", "")
|
|
47
|
+
|
|
48
|
+
charset = "utf-8"
|
|
49
|
+
|
|
50
|
+
for part in content_type.split(";")[1:]:
|
|
51
|
+
key, separator, value = part.strip().partition("=")
|
|
52
|
+
|
|
53
|
+
if separator and key.lower() == "charset":
|
|
54
|
+
charset = value.strip("\"'")
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return self.content.decode(charset)
|
|
59
|
+
except LookupError:
|
|
60
|
+
return self.content.decode("utf-8", errors="replace")
|
|
61
|
+
|
|
62
|
+
def json(self) -> Any:
|
|
63
|
+
"""Decode the response as JSON."""
|
|
64
|
+
return json_module.loads(self.text)
|
|
65
|
+
|
|
66
|
+
def raise_for_status(self):
|
|
67
|
+
"""Raise HTTPError if the status is unsuccessful."""
|
|
68
|
+
|
|
69
|
+
if not self.ok:
|
|
70
|
+
raise HTTPError(
|
|
71
|
+
self.status_code,
|
|
72
|
+
self.url,
|
|
73
|
+
self.text
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _append_params(url: str, params: Optional[dict]) -> str:
|
|
78
|
+
"""Append query parameters to an URL."""
|
|
79
|
+
|
|
80
|
+
if not params:
|
|
81
|
+
return url
|
|
82
|
+
|
|
83
|
+
parts = urlsplit(url)
|
|
84
|
+
query = urlencode(params, doseq=True)
|
|
85
|
+
|
|
86
|
+
if parts.query:
|
|
87
|
+
query = parts.query + "&" + query
|
|
88
|
+
|
|
89
|
+
return urlunsplit((
|
|
90
|
+
parts.scheme,
|
|
91
|
+
parts.netloc,
|
|
92
|
+
parts.path,
|
|
93
|
+
query,
|
|
94
|
+
parts.fragment
|
|
95
|
+
))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def request(
|
|
99
|
+
method: str,
|
|
100
|
+
url: str,
|
|
101
|
+
params: Optional[dict] = None,
|
|
102
|
+
headers: Optional[dict] = None,
|
|
103
|
+
data: Optional[Any] = None,
|
|
104
|
+
json: Optional[Any] = None,
|
|
105
|
+
timeout: float = 10.0,
|
|
106
|
+
verify=True,
|
|
107
|
+
ca_certs=None
|
|
108
|
+
) -> HTTPResponse:
|
|
109
|
+
"""
|
|
110
|
+
Perform an HTTP request.
|
|
111
|
+
|
|
112
|
+
Supported methods:
|
|
113
|
+
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
if not isinstance(method, str):
|
|
117
|
+
raise TypeError("Method must be a string")
|
|
118
|
+
|
|
119
|
+
method = method.upper()
|
|
120
|
+
|
|
121
|
+
allowed_methods = {
|
|
122
|
+
"GET", "POST", "PUT", "PATCH",
|
|
123
|
+
"DELETE", "HEAD", "OPTIONS"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if method not in allowed_methods:
|
|
127
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
128
|
+
|
|
129
|
+
if not isinstance(url, str):
|
|
130
|
+
raise TypeError("URL must be a string")
|
|
131
|
+
|
|
132
|
+
if not url.startswith(("http://", "https://")):
|
|
133
|
+
raise ValueError("URL must use HTTP or HTTPS")
|
|
134
|
+
|
|
135
|
+
if timeout <= 0:
|
|
136
|
+
raise ValueError("Timeout must be positive")
|
|
137
|
+
|
|
138
|
+
if data is not None and json is not None:
|
|
139
|
+
raise ValueError("Use either data or json, not both")
|
|
140
|
+
|
|
141
|
+
final_url = _append_params(url, params)
|
|
142
|
+
|
|
143
|
+
request_headers = dict(headers or {})
|
|
144
|
+
body = None
|
|
145
|
+
|
|
146
|
+
if json is not None:
|
|
147
|
+
body = json_module.dumps(json).encode("utf-8")
|
|
148
|
+
|
|
149
|
+
if not any(
|
|
150
|
+
key.lower() == "content-type"
|
|
151
|
+
for key in request_headers
|
|
152
|
+
):
|
|
153
|
+
request_headers["Content-Type"] = "application/json"
|
|
154
|
+
|
|
155
|
+
elif data is not None:
|
|
156
|
+
if isinstance(data, str):
|
|
157
|
+
body = data.encode("utf-8")
|
|
158
|
+
elif isinstance(data, bytes):
|
|
159
|
+
body = data
|
|
160
|
+
else:
|
|
161
|
+
raise TypeError("data must be str or bytes")
|
|
162
|
+
|
|
163
|
+
req = urllib.request.Request(
|
|
164
|
+
final_url,
|
|
165
|
+
data=body,
|
|
166
|
+
headers=request_headers,
|
|
167
|
+
method=method
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
if verify:
|
|
172
|
+
# Utilise certifi par défaut ou le certificat CA fourni.
|
|
173
|
+
context = ssl.create_default_context(
|
|
174
|
+
cafile=ca_certs or certifi.where()
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
warnings.warn(
|
|
178
|
+
"SSL certificate verification is disabled. "
|
|
179
|
+
"This connection is insecure.",
|
|
180
|
+
RuntimeWarning,
|
|
181
|
+
stacklevel=2,
|
|
182
|
+
)
|
|
183
|
+
context = ssl._create_unverified_context()
|
|
184
|
+
with urllib.request.urlopen(
|
|
185
|
+
req,
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
context=context
|
|
188
|
+
) as response:
|
|
189
|
+
|
|
190
|
+
return HTTPResponse(
|
|
191
|
+
status_code=response.status,
|
|
192
|
+
url=response.geturl(),
|
|
193
|
+
headers=dict(response.headers.items()),
|
|
194
|
+
content=response.read()
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
except urllib.error.HTTPError as error:
|
|
198
|
+
# HTTP errors such as 404 and 500 are still valid responses.
|
|
199
|
+
return HTTPResponse(
|
|
200
|
+
status_code=error.code,
|
|
201
|
+
url=error.geturl(),
|
|
202
|
+
headers=dict(error.headers.items()),
|
|
203
|
+
content=error.read()
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
except urllib.error.URLError as error:
|
|
207
|
+
raise NetworkError(
|
|
208
|
+
f"Unable to reach {url}: {error.reason}"
|
|
209
|
+
) from error
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get(url: str, **kwargs) -> HTTPResponse:
|
|
213
|
+
return request("GET", url, **kwargs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def post(url: str, **kwargs) -> HTTPResponse:
|
|
217
|
+
return request("POST", url, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def put(url: str, **kwargs) -> HTTPResponse:
|
|
221
|
+
return request("PUT", url, **kwargs)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def patch(url: str, **kwargs) -> HTTPResponse:
|
|
225
|
+
return request("PATCH", url, **kwargs)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def delete(url: str, **kwargs) -> HTTPResponse:
|
|
229
|
+
return request("DELETE", url, **kwargs)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def head(url: str, **kwargs) -> HTTPResponse:
|
|
233
|
+
return request("HEAD", url, **kwargs)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def options(url: str, **kwargs) -> HTTPResponse:
|
|
237
|
+
return request("OPTIONS", url, **kwargs)
|
netforge/network.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class NetworkInterface:
|
|
11
|
+
name: str
|
|
12
|
+
ipv4: tuple[str, ...]
|
|
13
|
+
ipv6: tuple[str, ...]
|
|
14
|
+
mac: Optional[str]
|
|
15
|
+
is_up: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DNSResult:
|
|
20
|
+
hostname: str
|
|
21
|
+
ipv4: tuple[str, ...]
|
|
22
|
+
ipv6: tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def interfaces() -> list[NetworkInterface]:
|
|
26
|
+
"""Return all available network interfaces."""
|
|
27
|
+
|
|
28
|
+
addresses = psutil.net_if_addrs()
|
|
29
|
+
stats = psutil.net_if_stats()
|
|
30
|
+
|
|
31
|
+
results = []
|
|
32
|
+
|
|
33
|
+
for name, entries in addresses.items():
|
|
34
|
+
ipv4 = []
|
|
35
|
+
ipv6 = []
|
|
36
|
+
mac = None
|
|
37
|
+
|
|
38
|
+
for entry in entries:
|
|
39
|
+
if entry.family == socket.AF_INET:
|
|
40
|
+
ipv4.append(entry.address)
|
|
41
|
+
|
|
42
|
+
elif entry.family == socket.AF_INET6:
|
|
43
|
+
address = entry.address.split("%")[0]
|
|
44
|
+
ipv6.append(address)
|
|
45
|
+
|
|
46
|
+
elif entry.family == getattr(psutil, "AF_LINK", object()):
|
|
47
|
+
mac = entry.address
|
|
48
|
+
|
|
49
|
+
interface_stats = stats.get(name)
|
|
50
|
+
|
|
51
|
+
results.append(
|
|
52
|
+
NetworkInterface(
|
|
53
|
+
name=name,
|
|
54
|
+
ipv4=tuple(dict.fromkeys(ipv4)),
|
|
55
|
+
ipv6=tuple(dict.fromkeys(ipv6)),
|
|
56
|
+
mac=mac,
|
|
57
|
+
is_up=interface_stats.isup if interface_stats else False
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve(host: str) -> DNSResult:
|
|
65
|
+
"""Resolve a hostname into IPv4 and IPv6 addresses."""
|
|
66
|
+
|
|
67
|
+
if not isinstance(host, str) or not host.strip():
|
|
68
|
+
raise ValueError("Host must be a non-empty string")
|
|
69
|
+
|
|
70
|
+
addresses = socket.getaddrinfo(
|
|
71
|
+
host,
|
|
72
|
+
None,
|
|
73
|
+
socket.AF_UNSPEC,
|
|
74
|
+
socket.SOCK_STREAM
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ipv4 = set()
|
|
78
|
+
ipv6 = set()
|
|
79
|
+
|
|
80
|
+
for result in addresses:
|
|
81
|
+
family = result[0]
|
|
82
|
+
address = result[4][0].split("%")[0]
|
|
83
|
+
|
|
84
|
+
if family == socket.AF_INET:
|
|
85
|
+
ipv4.add(address)
|
|
86
|
+
|
|
87
|
+
elif family == socket.AF_INET6:
|
|
88
|
+
ipv6.add(address)
|
|
89
|
+
|
|
90
|
+
return DNSResult(
|
|
91
|
+
hostname=host,
|
|
92
|
+
ipv4=tuple(sorted(ipv4)),
|
|
93
|
+
ipv6=tuple(sorted(ipv6))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def hostname() -> str:
|
|
98
|
+
"""Return the local machine hostname."""
|
|
99
|
+
|
|
100
|
+
return socket.gethostname()
|
netforge/ports.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
NetForge - TCP Port Scanner
|
|
4
|
+
|
|
5
|
+
Provides tools to inspect TCP ports on a remote or local host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PortResult:
|
|
18
|
+
host: str
|
|
19
|
+
port: int
|
|
20
|
+
status: str
|
|
21
|
+
latency_ms: float | None = None
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_open(self) -> bool:
|
|
26
|
+
return self.status == "open"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_port(port: int) -> None:
|
|
30
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
31
|
+
raise TypeError("Port must be an integer")
|
|
32
|
+
|
|
33
|
+
if not 1 <= port <= 65535:
|
|
34
|
+
raise ValueError("Port must be between 1 and 65535")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_port(
|
|
38
|
+
host: str,
|
|
39
|
+
port: int,
|
|
40
|
+
timeout: float = 1.0,
|
|
41
|
+
) -> PortResult:
|
|
42
|
+
"""
|
|
43
|
+
Check whether a TCP port is open.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
host: Hostname or IP address.
|
|
47
|
+
port: TCP port number (1-65535).
|
|
48
|
+
timeout: Maximum connection time in seconds.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A PortResult containing the port status.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if not isinstance(host, str) or not host.strip():
|
|
55
|
+
raise ValueError("Host must be a non-empty string")
|
|
56
|
+
|
|
57
|
+
_validate_port(port)
|
|
58
|
+
|
|
59
|
+
if timeout <= 0:
|
|
60
|
+
raise ValueError("Timeout must be greater than zero")
|
|
61
|
+
|
|
62
|
+
start = time.perf_counter()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with socket.create_connection(
|
|
66
|
+
(host, port),
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
):
|
|
69
|
+
latency = (time.perf_counter() - start) * 1000
|
|
70
|
+
|
|
71
|
+
return PortResult(
|
|
72
|
+
host=host,
|
|
73
|
+
port=port,
|
|
74
|
+
status="open",
|
|
75
|
+
latency_ms=round(latency, 2),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
except socket.timeout as error:
|
|
79
|
+
return PortResult(
|
|
80
|
+
host=host,
|
|
81
|
+
port=port,
|
|
82
|
+
status="filtered",
|
|
83
|
+
error=str(error) or "Connection timed out",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
except ConnectionRefusedError as error:
|
|
87
|
+
return PortResult(
|
|
88
|
+
host=host,
|
|
89
|
+
port=port,
|
|
90
|
+
status="closed",
|
|
91
|
+
error=str(error),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
except OSError as error:
|
|
95
|
+
return PortResult(
|
|
96
|
+
host=host,
|
|
97
|
+
port=port,
|
|
98
|
+
status="error",
|
|
99
|
+
error=str(error),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def scan_ports(
|
|
104
|
+
host: str,
|
|
105
|
+
ports: Iterable[int],
|
|
106
|
+
timeout: float = 1.0,
|
|
107
|
+
max_workers: int = 50,
|
|
108
|
+
) -> list[PortResult]:
|
|
109
|
+
"""
|
|
110
|
+
Scan multiple TCP ports concurrently.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
host: Hostname or IP address.
|
|
114
|
+
ports: Iterable of TCP port numbers.
|
|
115
|
+
timeout: Timeout for each connection.
|
|
116
|
+
max_workers: Maximum number of concurrent workers.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A list of PortResult objects, sorted by port number.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
if not isinstance(host, str) or not host.strip():
|
|
123
|
+
raise ValueError("Host must be a non-empty string")
|
|
124
|
+
|
|
125
|
+
if timeout <= 0:
|
|
126
|
+
raise ValueError("Timeout must be greater than zero")
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
isinstance(max_workers, bool)
|
|
130
|
+
or not isinstance(max_workers, int)
|
|
131
|
+
or max_workers < 1
|
|
132
|
+
):
|
|
133
|
+
raise ValueError("max_workers must be a positive integer")
|
|
134
|
+
|
|
135
|
+
port_list = list(ports)
|
|
136
|
+
|
|
137
|
+
for port in port_list:
|
|
138
|
+
_validate_port(port)
|
|
139
|
+
|
|
140
|
+
# Remove duplicates and sort ports.
|
|
141
|
+
port_list = sorted(set(port_list))
|
|
142
|
+
|
|
143
|
+
if not port_list:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
147
|
+
results = executor.map(
|
|
148
|
+
lambda port: check_port(host, port, timeout),
|
|
149
|
+
port_list,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return list(results)
|
netforge/servers.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
NetForge - HTTP Server
|
|
4
|
+
|
|
5
|
+
A small multithreaded HTTP server with route handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from typing import Callable
|
|
14
|
+
from urllib.parse import parse_qs, urlsplit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ServerRequest:
|
|
19
|
+
method: str
|
|
20
|
+
path: str
|
|
21
|
+
query: dict[str, list[str]]
|
|
22
|
+
headers: dict[str, str]
|
|
23
|
+
body: bytes
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def text(self) -> str:
|
|
27
|
+
return self.body.decode("utf-8", errors="replace")
|
|
28
|
+
|
|
29
|
+
def json(self):
|
|
30
|
+
return json.loads(self.body.decode("utf-8"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ServerResponse:
|
|
35
|
+
body: bytes = b""
|
|
36
|
+
status: int = 200
|
|
37
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def text(
|
|
41
|
+
cls,
|
|
42
|
+
content: str,
|
|
43
|
+
status: int = 200,
|
|
44
|
+
headers: dict[str, str] | None = None,
|
|
45
|
+
):
|
|
46
|
+
response_headers = {
|
|
47
|
+
"Content-Type": "text/plain; charset=utf-8"
|
|
48
|
+
}
|
|
49
|
+
if headers:
|
|
50
|
+
response_headers.update(headers)
|
|
51
|
+
|
|
52
|
+
return cls(
|
|
53
|
+
body=content.encode("utf-8"),
|
|
54
|
+
status=status,
|
|
55
|
+
headers=response_headers,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def json(
|
|
60
|
+
cls,
|
|
61
|
+
data,
|
|
62
|
+
status: int = 200,
|
|
63
|
+
headers: dict[str, str] | None = None,
|
|
64
|
+
):
|
|
65
|
+
response_headers = {
|
|
66
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
67
|
+
}
|
|
68
|
+
if headers:
|
|
69
|
+
response_headers.update(headers)
|
|
70
|
+
|
|
71
|
+
return cls(
|
|
72
|
+
body=json.dumps(data, ensure_ascii=False).encode("utf-8"),
|
|
73
|
+
status=status,
|
|
74
|
+
headers=response_headers,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
RouteHandler = Callable[[ServerRequest], ServerResponse]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HTTPServer:
|
|
82
|
+
"""
|
|
83
|
+
Multithreaded HTTP server with simple route registration.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
host: str = "127.0.0.1",
|
|
89
|
+
port: int = 8000,
|
|
90
|
+
max_body_size: int = 1_048_576,
|
|
91
|
+
):
|
|
92
|
+
if not isinstance(host, str) or not host.strip():
|
|
93
|
+
raise ValueError("Host must be a non-empty string")
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
isinstance(port, bool)
|
|
97
|
+
or not isinstance(port, int)
|
|
98
|
+
or not 0 <= port <= 65535
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("Port must be between 0 and 65535")
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
isinstance(max_body_size, bool)
|
|
104
|
+
or not isinstance(max_body_size, int)
|
|
105
|
+
or max_body_size < 0
|
|
106
|
+
):
|
|
107
|
+
raise ValueError("max_body_size must be a non-negative integer")
|
|
108
|
+
|
|
109
|
+
self.host = host
|
|
110
|
+
self.port = port
|
|
111
|
+
self.max_body_size = max_body_size
|
|
112
|
+
|
|
113
|
+
self._routes: dict[tuple[str, str], RouteHandler] = {}
|
|
114
|
+
self._httpd = None
|
|
115
|
+
self._thread = None
|
|
116
|
+
self._lock = threading.RLock()
|
|
117
|
+
|
|
118
|
+
def route(self, method: str, path: str):
|
|
119
|
+
"""
|
|
120
|
+
Register a handler for an HTTP method and path.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
@server.route("GET", "/")
|
|
124
|
+
def home(request):
|
|
125
|
+
return ServerResponse.text("Hello!")
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
method = method.upper()
|
|
129
|
+
|
|
130
|
+
if not method.isalpha():
|
|
131
|
+
raise ValueError("Invalid HTTP method")
|
|
132
|
+
|
|
133
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
134
|
+
raise ValueError("Route path must start with '/'")
|
|
135
|
+
|
|
136
|
+
def decorator(handler: RouteHandler):
|
|
137
|
+
if not callable(handler):
|
|
138
|
+
raise TypeError("Handler must be callable")
|
|
139
|
+
|
|
140
|
+
key = (method, path)
|
|
141
|
+
|
|
142
|
+
with self._lock:
|
|
143
|
+
if key in self._routes:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Route already registered: {method} {path}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self._routes[key] = handler
|
|
149
|
+
|
|
150
|
+
return handler
|
|
151
|
+
|
|
152
|
+
return decorator
|
|
153
|
+
|
|
154
|
+
def _make_handler(self):
|
|
155
|
+
server = self
|
|
156
|
+
|
|
157
|
+
class RequestHandler(BaseHTTPRequestHandler):
|
|
158
|
+
|
|
159
|
+
def _handle(self):
|
|
160
|
+
parsed = urlsplit(self.path)
|
|
161
|
+
path = parsed.path
|
|
162
|
+
method = self.command.upper()
|
|
163
|
+
|
|
164
|
+
length_header = self.headers.get("Content-Length", "0")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
length = int(length_header)
|
|
168
|
+
except ValueError:
|
|
169
|
+
self._send_response(
|
|
170
|
+
ServerResponse.text("Invalid Content-Length", 400)
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if length < 0:
|
|
175
|
+
self._send_response(
|
|
176
|
+
ServerResponse.text("Invalid Content-Length", 400)
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if length > server.max_body_size:
|
|
181
|
+
self._send_response(
|
|
182
|
+
ServerResponse.text("Request body too large", 413)
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
body = self.rfile.read(length) if length else b""
|
|
187
|
+
|
|
188
|
+
request = ServerRequest(
|
|
189
|
+
method=method,
|
|
190
|
+
path=path,
|
|
191
|
+
query=parse_qs(parsed.query),
|
|
192
|
+
headers=dict(self.headers.items()),
|
|
193
|
+
body=body,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with server._lock:
|
|
197
|
+
handler = server._routes.get((method, path))
|
|
198
|
+
|
|
199
|
+
if handler is None:
|
|
200
|
+
response = ServerResponse.json(
|
|
201
|
+
{"error": "Not Found", "path": path},
|
|
202
|
+
status=404,
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
try:
|
|
206
|
+
response = handler(request)
|
|
207
|
+
|
|
208
|
+
if not isinstance(response, ServerResponse):
|
|
209
|
+
raise TypeError(
|
|
210
|
+
"Route handlers must return ServerResponse"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
except Exception:
|
|
214
|
+
response = ServerResponse.json(
|
|
215
|
+
{"error": "Internal Server Error"},
|
|
216
|
+
status=500,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._send_response(response)
|
|
220
|
+
|
|
221
|
+
def _send_response(self, response: ServerResponse):
|
|
222
|
+
self.send_response(response.status)
|
|
223
|
+
|
|
224
|
+
headers = dict(response.headers)
|
|
225
|
+
headers.setdefault("Content-Length", str(len(response.body)))
|
|
226
|
+
|
|
227
|
+
for name, value in headers.items():
|
|
228
|
+
self.send_header(name, value)
|
|
229
|
+
|
|
230
|
+
self.end_headers()
|
|
231
|
+
|
|
232
|
+
if self.command != "HEAD" and response.body:
|
|
233
|
+
self.wfile.write(response.body)
|
|
234
|
+
|
|
235
|
+
def do_GET(self):
|
|
236
|
+
self._handle()
|
|
237
|
+
|
|
238
|
+
def do_POST(self):
|
|
239
|
+
self._handle()
|
|
240
|
+
|
|
241
|
+
def do_PUT(self):
|
|
242
|
+
self._handle()
|
|
243
|
+
|
|
244
|
+
def do_PATCH(self):
|
|
245
|
+
self._handle()
|
|
246
|
+
|
|
247
|
+
def do_DELETE(self):
|
|
248
|
+
self._handle()
|
|
249
|
+
|
|
250
|
+
def do_HEAD(self):
|
|
251
|
+
self._handle()
|
|
252
|
+
|
|
253
|
+
def do_OPTIONS(self):
|
|
254
|
+
self._handle()
|
|
255
|
+
|
|
256
|
+
def log_message(self, format, *args):
|
|
257
|
+
# Keep the console clean by default.
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
return RequestHandler
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def address(self) -> tuple[str, int]:
|
|
264
|
+
if self._httpd is None:
|
|
265
|
+
return self.host, self.port
|
|
266
|
+
|
|
267
|
+
host, port = self._httpd.server_address[:2]
|
|
268
|
+
return host, port
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def is_running(self) -> bool:
|
|
272
|
+
return (
|
|
273
|
+
self._thread is not None
|
|
274
|
+
and self._thread.is_alive()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def serve_forever(self):
|
|
278
|
+
"""Start the server in the current thread."""
|
|
279
|
+
|
|
280
|
+
if self._httpd is not None:
|
|
281
|
+
raise RuntimeError("Server has already been started")
|
|
282
|
+
|
|
283
|
+
self._httpd = ThreadingHTTPServer(
|
|
284
|
+
(self.host, self.port),
|
|
285
|
+
self._make_handler(),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
self._httpd.serve_forever()
|
|
290
|
+
finally:
|
|
291
|
+
self._httpd.server_close()
|
|
292
|
+
self._httpd = None
|
|
293
|
+
|
|
294
|
+
def start(self):
|
|
295
|
+
"""Start the server in a background thread."""
|
|
296
|
+
|
|
297
|
+
if self._httpd is not None:
|
|
298
|
+
raise RuntimeError("Server has already been started")
|
|
299
|
+
|
|
300
|
+
self._httpd = ThreadingHTTPServer(
|
|
301
|
+
(self.host, self.port),
|
|
302
|
+
self._make_handler(),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
self._thread = threading.Thread(
|
|
306
|
+
target=self._httpd.serve_forever,
|
|
307
|
+
daemon=True,
|
|
308
|
+
)
|
|
309
|
+
self._thread.start()
|
|
310
|
+
|
|
311
|
+
return self
|
|
312
|
+
|
|
313
|
+
def shutdown(self):
|
|
314
|
+
"""Stop the server."""
|
|
315
|
+
|
|
316
|
+
if self._httpd is None:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
httpd = self._httpd
|
|
320
|
+
httpd.shutdown()
|
|
321
|
+
httpd.server_close()
|
|
322
|
+
|
|
323
|
+
if self._thread is not None:
|
|
324
|
+
self._thread.join(timeout=5)
|
|
325
|
+
|
|
326
|
+
self._httpd = None
|
|
327
|
+
self._thread = None
|
|
328
|
+
|
|
329
|
+
def close(self):
|
|
330
|
+
self.shutdown()
|
|
331
|
+
|
|
332
|
+
def __enter__(self):
|
|
333
|
+
return self.start()
|
|
334
|
+
|
|
335
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
336
|
+
self.shutdown()
|
netforge/sockets.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SocketConnection:
|
|
8
|
+
"""Manage a TCP or UDP socket connection."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, sock: socket.socket, protocol: str):
|
|
11
|
+
self._socket = sock
|
|
12
|
+
self.protocol = protocol.lower()
|
|
13
|
+
self.closed = False
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def address(self):
|
|
17
|
+
"""Return the remote address."""
|
|
18
|
+
return self._socket.getpeername()
|
|
19
|
+
|
|
20
|
+
def send(self, data: bytes) -> int:
|
|
21
|
+
"""Send bytes through the connection."""
|
|
22
|
+
|
|
23
|
+
if self.closed:
|
|
24
|
+
raise RuntimeError("Socket is closed")
|
|
25
|
+
|
|
26
|
+
if not isinstance(data, bytes):
|
|
27
|
+
raise TypeError("Data must be bytes")
|
|
28
|
+
|
|
29
|
+
if self.protocol == "tcp":
|
|
30
|
+
self._socket.sendall(data)
|
|
31
|
+
return len(data)
|
|
32
|
+
|
|
33
|
+
return self._socket.send(data)
|
|
34
|
+
|
|
35
|
+
def receive(self, max_bytes: int = 4096) -> bytes:
|
|
36
|
+
"""Receive bytes from the connection."""
|
|
37
|
+
|
|
38
|
+
if self.closed:
|
|
39
|
+
raise RuntimeError("Socket is closed")
|
|
40
|
+
|
|
41
|
+
if max_bytes <= 0:
|
|
42
|
+
raise ValueError("max_bytes must be positive")
|
|
43
|
+
|
|
44
|
+
return self._socket.recv(max_bytes)
|
|
45
|
+
|
|
46
|
+
def close(self):
|
|
47
|
+
"""Close the connection."""
|
|
48
|
+
|
|
49
|
+
if not self.closed:
|
|
50
|
+
self._socket.close()
|
|
51
|
+
self.closed = True
|
|
52
|
+
|
|
53
|
+
def __enter__(self):
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TCPServer:
|
|
61
|
+
"""A basic TCP server."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
host: str,
|
|
66
|
+
port: int,
|
|
67
|
+
backlog: int = 5,
|
|
68
|
+
timeout: Optional[float] = None
|
|
69
|
+
):
|
|
70
|
+
if not 0 <= port <= 65535:
|
|
71
|
+
raise ValueError("Invalid port")
|
|
72
|
+
|
|
73
|
+
if backlog <= 0:
|
|
74
|
+
raise ValueError("backlog must be positive")
|
|
75
|
+
|
|
76
|
+
self._socket = socket.socket(
|
|
77
|
+
socket.AF_INET,
|
|
78
|
+
socket.SOCK_STREAM
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._socket.setsockopt(
|
|
82
|
+
socket.SOL_SOCKET,
|
|
83
|
+
socket.SO_REUSEADDR,
|
|
84
|
+
1
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self._socket.settimeout(timeout)
|
|
88
|
+
|
|
89
|
+
self._socket.bind((host, port))
|
|
90
|
+
self._socket.listen(backlog)
|
|
91
|
+
|
|
92
|
+
self.closed = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def address(self):
|
|
96
|
+
"""Return the server address."""
|
|
97
|
+
return self._socket.getsockname()
|
|
98
|
+
|
|
99
|
+
def accept(self):
|
|
100
|
+
"""Accept an incoming TCP connection."""
|
|
101
|
+
|
|
102
|
+
if self.closed:
|
|
103
|
+
raise RuntimeError("Server is closed")
|
|
104
|
+
|
|
105
|
+
client, address = self._socket.accept()
|
|
106
|
+
|
|
107
|
+
return SocketConnection(client, "tcp"), address
|
|
108
|
+
|
|
109
|
+
def close(self):
|
|
110
|
+
"""Stop the server."""
|
|
111
|
+
|
|
112
|
+
if not self.closed:
|
|
113
|
+
self._socket.close()
|
|
114
|
+
self.closed = True
|
|
115
|
+
|
|
116
|
+
def __enter__(self):
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
120
|
+
self.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def connect(
|
|
124
|
+
host: str,
|
|
125
|
+
port: int,
|
|
126
|
+
protocol: str = "tcp",
|
|
127
|
+
timeout: Optional[float] = 5.0
|
|
128
|
+
) -> SocketConnection:
|
|
129
|
+
"""
|
|
130
|
+
Connect to a remote host using TCP or UDP.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
protocol = protocol.lower()
|
|
134
|
+
|
|
135
|
+
if protocol not in ("tcp", "udp"):
|
|
136
|
+
raise ValueError("Protocol must be 'tcp' or 'udp'")
|
|
137
|
+
|
|
138
|
+
if not isinstance(host, str) or not host.strip():
|
|
139
|
+
raise ValueError("Host must be a non-empty string")
|
|
140
|
+
|
|
141
|
+
if not isinstance(port, int) or not 1 <= port <= 65535:
|
|
142
|
+
raise ValueError("Port must be between 1 and 65535")
|
|
143
|
+
|
|
144
|
+
if timeout is not None and timeout < 0:
|
|
145
|
+
raise ValueError("Timeout cannot be negative")
|
|
146
|
+
|
|
147
|
+
if protocol == "tcp":
|
|
148
|
+
sock = socket.create_connection(
|
|
149
|
+
(host, port),
|
|
150
|
+
timeout=timeout
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
else:
|
|
154
|
+
addresses = socket.getaddrinfo(
|
|
155
|
+
host,
|
|
156
|
+
port,
|
|
157
|
+
type=socket.SOCK_DGRAM
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
last_error = None
|
|
161
|
+
sock = None
|
|
162
|
+
|
|
163
|
+
for family, socktype, proto, _, address in addresses:
|
|
164
|
+
candidate = socket.socket(family, socktype, proto)
|
|
165
|
+
candidate.settimeout(timeout)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
candidate.connect(address)
|
|
169
|
+
sock = candidate
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
except OSError as error:
|
|
173
|
+
last_error = error
|
|
174
|
+
candidate.close()
|
|
175
|
+
|
|
176
|
+
if sock is None:
|
|
177
|
+
if last_error:
|
|
178
|
+
raise last_error
|
|
179
|
+
raise OSError("Unable to establish UDP connection")
|
|
180
|
+
|
|
181
|
+
return SocketConnection(sock, protocol)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def listen(
|
|
185
|
+
host: str = "127.0.0.1",
|
|
186
|
+
port: int = 5000,
|
|
187
|
+
backlog: int = 5,
|
|
188
|
+
timeout: Optional[float] = None
|
|
189
|
+
) -> TCPServer:
|
|
190
|
+
"""Create and start a TCP server."""
|
|
191
|
+
|
|
192
|
+
return TCPServer(
|
|
193
|
+
host=host,
|
|
194
|
+
port=port,
|
|
195
|
+
backlog=backlog,
|
|
196
|
+
timeout=timeout
|
|
197
|
+
)
|
netforge/system.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import socket
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SystemInfo:
|
|
10
|
+
hostname: str
|
|
11
|
+
os: str
|
|
12
|
+
os_version: str
|
|
13
|
+
architecture: str
|
|
14
|
+
python_version: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def info() -> SystemInfo:
|
|
18
|
+
"""Return information about the current machine."""
|
|
19
|
+
|
|
20
|
+
return SystemInfo(
|
|
21
|
+
hostname=socket.gethostname(),
|
|
22
|
+
os=platform.system(),
|
|
23
|
+
os_version=platform.release(),
|
|
24
|
+
architecture=platform.machine(),
|
|
25
|
+
python_version=sys.version.split()[0]
|
|
26
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netforge-network-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python network and system toolkit
|
|
5
|
+
Author: NetForge Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: psutil>=5.9
|
|
10
|
+
Requires-Dist: certifi>=2024.2.2
|
|
11
|
+
|
|
12
|
+
# NetForge
|
|
13
|
+
|
|
14
|
+
Python Network & System Toolkit.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- System information
|
|
19
|
+
- Network interfaces
|
|
20
|
+
- TCP and UDP sockets
|
|
21
|
+
- HTTP requests
|
|
22
|
+
- Port diagnostics
|
|
23
|
+
- Server creation
|
|
24
|
+
- Network diagnostics
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
pip install netforge
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
netforge/__init__.py,sha256=upOPi5VSejzhO55m59CMYm30Xv8esb_qJ04X6RIFFQY,310
|
|
2
|
+
netforge/diagnostics.py,sha256=W8Sc0ODC1P27PrK-OvrqKuRthp5DmDXM8e26bBmDrM4,4311
|
|
3
|
+
netforge/http.py,sha256=shpMm9ngtPzBnunhGflQK-OlpmWapPtDtdOhYxJQLHc,6199
|
|
4
|
+
netforge/network.py,sha256=hvjioZeIEOVLk-XLGssBpHhe42c5bFU1KBiCIYu8MN0,2291
|
|
5
|
+
netforge/ports.py,sha256=Wkq5_BNGDO_zcvcA7c9hisq9lDksG9IRk-eXl1SiKQo,3685
|
|
6
|
+
netforge/servers.py,sha256=Nw-Cmp4ozgav6U72B1O_OxbYqL8BIE_Cp2OzbDYBXj8,9251
|
|
7
|
+
netforge/sockets.py,sha256=JyNcQ_cROEvaEPhg0AaphD51bP3FCbHmxaBG3NFSxKw,4820
|
|
8
|
+
netforge/system.py,sha256=EXLuFqAEaeG54HVVnK5Zl_sMAQTKDQ2--93_ZPcUaGo,536
|
|
9
|
+
netforge_network_toolkit-0.1.0.dist-info/METADATA,sha256=THeg4W35wjTTAH6lmlZwuAFMbJFDh-CwYwyP-lEhh4Y,557
|
|
10
|
+
netforge_network_toolkit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
netforge_network_toolkit-0.1.0.dist-info/top_level.txt,sha256=ggqGwPJot8thNMrgeLIp-SpweSr4-uPNagHyUJwPrbw,9
|
|
12
|
+
netforge_network_toolkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
netforge
|