netforge-network-toolkit 0.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.
- netforge_network_toolkit-0.1.0/PKG-INFO +28 -0
- netforge_network_toolkit-0.1.0/README.md +17 -0
- netforge_network_toolkit-0.1.0/pyproject.toml +21 -0
- netforge_network_toolkit-0.1.0/setup.cfg +4 -0
- netforge_network_toolkit-0.1.0/src/netforge/__init__.py +19 -0
- netforge_network_toolkit-0.1.0/src/netforge/diagnostics.py +180 -0
- netforge_network_toolkit-0.1.0/src/netforge/http.py +237 -0
- netforge_network_toolkit-0.1.0/src/netforge/network.py +100 -0
- netforge_network_toolkit-0.1.0/src/netforge/ports.py +152 -0
- netforge_network_toolkit-0.1.0/src/netforge/servers.py +336 -0
- netforge_network_toolkit-0.1.0/src/netforge/sockets.py +197 -0
- netforge_network_toolkit-0.1.0/src/netforge/system.py +26 -0
- netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/PKG-INFO +28 -0
- netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/SOURCES.txt +28 -0
- netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/dependency_links.txt +1 -0
- netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/requires.txt +2 -0
- netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/top_level.txt +1 -0
- netforge_network_toolkit-0.1.0/tests/test_diagnostics.py +60 -0
- netforge_network_toolkit-0.1.0/tests/test_http.py +65 -0
- netforge_network_toolkit-0.1.0/tests/test_network.py +34 -0
- netforge_network_toolkit-0.1.0/tests/test_ports.py +68 -0
- netforge_network_toolkit-0.1.0/tests/test_servers.py +82 -0
- netforge_network_toolkit-0.1.0/tests/test_sockets.py +74 -0
- netforge_network_toolkit-0.1.0/tests/test_system.py +32 -0
- netforge_network_toolkit-0.1.0/tests/test_udp.py +61 -0
|
@@ -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,17 @@
|
|
|
1
|
+
# NetForge
|
|
2
|
+
|
|
3
|
+
Python Network & System Toolkit.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- System information
|
|
8
|
+
- Network interfaces
|
|
9
|
+
- TCP and UDP sockets
|
|
10
|
+
- HTTP requests
|
|
11
|
+
- Port diagnostics
|
|
12
|
+
- Server creation
|
|
13
|
+
- Network diagnostics
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
pip install netforge
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "netforge-network-toolkit"
|
|
7
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "A Python network and system toolkit"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"psutil>=5.9",
|
|
13
|
+
"certifi>=2024.2.2",
|
|
14
|
+
]
|
|
15
|
+
license = "MIT"
|
|
16
|
+
authors = [
|
|
17
|
+
{name = "NetForge Contributors"}
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src/"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|
|
@@ -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)
|
|
@@ -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()
|