neptls 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.
- neptls/__init__.py +52 -0
- neptls/__main__.py +3 -0
- neptls/async_client.py +52 -0
- neptls/cli.py +76 -0
- neptls/client.py +285 -0
- neptls/crypto.py +62 -0
- neptls/diagnostics.py +59 -0
- neptls/exceptions.py +29 -0
- neptls/fingerprints.py +76 -0
- neptls/models.py +120 -0
- neptls/pools.py +58 -0
- neptls/pow.py +54 -0
- neptls/profiles.py +81 -0
- neptls/proxy.py +35 -0
- neptls/py.typed +0 -0
- neptls/tls.py +118 -0
- neptls/user_agents.py +110 -0
- neptls-0.1.0.dist-info/METADATA +180 -0
- neptls-0.1.0.dist-info/RECORD +23 -0
- neptls-0.1.0.dist-info/WHEEL +5 -0
- neptls-0.1.0.dist-info/entry_points.txt +2 -0
- neptls-0.1.0.dist-info/licenses/LICENSE +21 -0
- neptls-0.1.0.dist-info/top_level.txt +1 -0
neptls/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""NepTLS: an HTTP client and TLS research toolkit.
|
|
2
|
+
|
|
3
|
+
Developed by Diwas Khatri (@diwaskhatri07).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .async_client import AsyncClient
|
|
7
|
+
from .client import Client, delete, get, head, options, patch, post, put, request
|
|
8
|
+
from .diagnostics import diagnose, inspect
|
|
9
|
+
from .exceptions import HTTPStatusError, NepTLSError, RequestError, TimeoutError
|
|
10
|
+
from .fingerprints import ClientFingerprint
|
|
11
|
+
from .models import RequestTiming, Response
|
|
12
|
+
from .profiles import BrowserProfile, PROFILES, get_profile
|
|
13
|
+
from .proxy import Proxy
|
|
14
|
+
from .tls import TLSConfig, TLSFingerprint
|
|
15
|
+
from . import crypto, fingerprints as fingerprint, profiles, pow, user_agents
|
|
16
|
+
from .user_agents import ua
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncClient",
|
|
22
|
+
"BrowserProfile",
|
|
23
|
+
"Client",
|
|
24
|
+
"ClientFingerprint",
|
|
25
|
+
"HTTPStatusError",
|
|
26
|
+
"NepTLSError",
|
|
27
|
+
"PROFILES",
|
|
28
|
+
"Proxy",
|
|
29
|
+
"RequestError",
|
|
30
|
+
"RequestTiming",
|
|
31
|
+
"Response",
|
|
32
|
+
"TLSConfig",
|
|
33
|
+
"TLSFingerprint",
|
|
34
|
+
"TimeoutError",
|
|
35
|
+
"crypto",
|
|
36
|
+
"delete",
|
|
37
|
+
"diagnose",
|
|
38
|
+
"fingerprint",
|
|
39
|
+
"get",
|
|
40
|
+
"get_profile",
|
|
41
|
+
"head",
|
|
42
|
+
"inspect",
|
|
43
|
+
"options",
|
|
44
|
+
"patch",
|
|
45
|
+
"post",
|
|
46
|
+
"pow",
|
|
47
|
+
"profiles",
|
|
48
|
+
"put",
|
|
49
|
+
"request",
|
|
50
|
+
"ua",
|
|
51
|
+
"user_agents",
|
|
52
|
+
]
|
neptls/__main__.py
ADDED
neptls/async_client.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Async API backed by the same tested synchronous transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .models import Response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncClient:
|
|
13
|
+
"""An asyncio-friendly client with a persistent cookie/profile session."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
16
|
+
self._client = Client(**kwargs)
|
|
17
|
+
|
|
18
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
|
19
|
+
return await asyncio.to_thread(self._client.request, method, url, **kwargs)
|
|
20
|
+
|
|
21
|
+
async def get(self, url: str, **kwargs: Any) -> Response:
|
|
22
|
+
return await self.request("GET", url, **kwargs)
|
|
23
|
+
|
|
24
|
+
async def post(self, url: str, **kwargs: Any) -> Response:
|
|
25
|
+
return await self.request("POST", url, **kwargs)
|
|
26
|
+
|
|
27
|
+
async def put(self, url: str, **kwargs: Any) -> Response:
|
|
28
|
+
return await self.request("PUT", url, **kwargs)
|
|
29
|
+
|
|
30
|
+
async def patch(self, url: str, **kwargs: Any) -> Response:
|
|
31
|
+
return await self.request("PATCH", url, **kwargs)
|
|
32
|
+
|
|
33
|
+
async def delete(self, url: str, **kwargs: Any) -> Response:
|
|
34
|
+
return await self.request("DELETE", url, **kwargs)
|
|
35
|
+
|
|
36
|
+
async def head(self, url: str, **kwargs: Any) -> Response:
|
|
37
|
+
return await self.request("HEAD", url, **kwargs)
|
|
38
|
+
|
|
39
|
+
async def options(self, url: str, **kwargs: Any) -> Response:
|
|
40
|
+
return await self.request("OPTIONS", url, **kwargs)
|
|
41
|
+
|
|
42
|
+
def fingerprint(self):
|
|
43
|
+
return self._client.fingerprint()
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
await asyncio.to_thread(self._client.close)
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, *_: object) -> None:
|
|
52
|
+
await self.close()
|
neptls/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command-line interface for NepTLS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from . import __version__, get, profiles, ua
|
|
11
|
+
from .crypto import blake2, md5, sha256, sha512
|
|
12
|
+
from .diagnostics import inspect
|
|
13
|
+
from .pow import Challenge, solve
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="neptls", description="NepTLS networking research toolkit")
|
|
18
|
+
sub = parser.add_subparsers(dest="command")
|
|
19
|
+
for method in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
20
|
+
command = sub.add_parser(method, help=f"perform an HTTP {method.upper()} request")
|
|
21
|
+
command.add_argument("url")
|
|
22
|
+
command.add_argument("--json", dest="json_body", help="JSON request body")
|
|
23
|
+
command.add_argument("--header", action="append", default=[], metavar="NAME:VALUE")
|
|
24
|
+
for name in ("inspect", "diagnose"):
|
|
25
|
+
command = sub.add_parser(name, help="inspect DNS, TCP, TLS, and HTTP")
|
|
26
|
+
command.add_argument("url")
|
|
27
|
+
command.add_argument("--json", action="store_true", dest="as_json")
|
|
28
|
+
sub.add_parser("version")
|
|
29
|
+
profile_command = sub.add_parser("profile", help="list browser profiles")
|
|
30
|
+
profile_command.add_argument("name", nargs="?")
|
|
31
|
+
ua_command = sub.add_parser("ua", help="query the user-agent catalog")
|
|
32
|
+
ua_command.add_argument("kind", nargs="?", default="random", choices=("random", "chrome", "firefox", "mobile"))
|
|
33
|
+
hash_command = sub.add_parser("hash", help="hash text")
|
|
34
|
+
hash_command.add_argument("value")
|
|
35
|
+
hash_command.add_argument("--algorithm", choices=("sha256", "sha512", "blake2", "md5"), default="sha256")
|
|
36
|
+
pow_command = sub.add_parser("pow", help="solve a generic proof-of-work challenge")
|
|
37
|
+
pow_command.add_argument("payload")
|
|
38
|
+
pow_command.add_argument("--difficulty", type=int, default=4)
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
args = _parser().parse_args(argv)
|
|
44
|
+
if args.command == "version":
|
|
45
|
+
print(__version__)
|
|
46
|
+
return 0
|
|
47
|
+
if args.command in {"get", "post", "put", "patch", "delete", "head", "options"}:
|
|
48
|
+
headers = dict(item.split(":", 1) for item in args.header if ":" in item)
|
|
49
|
+
body = json.loads(args.json_body) if args.json_body else None
|
|
50
|
+
response = getattr(__import__("neptls"), args.command)(args.url, headers=headers, json=body)
|
|
51
|
+
print(response.text)
|
|
52
|
+
return 0 if response.ok else 1
|
|
53
|
+
if args.command in {"inspect", "diagnose"}:
|
|
54
|
+
print(json.dumps(inspect(args.url), indent=2, default=str))
|
|
55
|
+
return 0
|
|
56
|
+
if args.command == "profile":
|
|
57
|
+
value = profiles.PROFILES if not args.name else profiles.get_profile(args.name).to_dict()
|
|
58
|
+
print(json.dumps(value, indent=2, default=str))
|
|
59
|
+
return 0
|
|
60
|
+
if args.command == "ua":
|
|
61
|
+
print(getattr(ua, args.kind)())
|
|
62
|
+
return 0
|
|
63
|
+
if args.command == "hash":
|
|
64
|
+
function = {"sha256": sha256, "sha512": sha512, "blake2": blake2, "md5": md5}[args.algorithm]
|
|
65
|
+
print(function(args.value))
|
|
66
|
+
return 0
|
|
67
|
+
if args.command == "pow":
|
|
68
|
+
result = solve(Challenge(args.payload, difficulty=args.difficulty))
|
|
69
|
+
print(json.dumps(asdict(result), default=str))
|
|
70
|
+
return 0
|
|
71
|
+
_parser().print_help()
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
neptls/client.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Synchronous requests-like HTTP client built on Python's standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json as json_module
|
|
7
|
+
import mimetypes
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from http.cookiejar import CookieJar
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .exceptions import RequestError, TimeoutError
|
|
19
|
+
from .models import RequestTiming, Response
|
|
20
|
+
from .profiles import BrowserProfile, get_profile
|
|
21
|
+
from .tls import TLSConfig, TLSFingerprint
|
|
22
|
+
|
|
23
|
+
AuthHook = Callable[[urllib.request.Request], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
27
|
+
def redirect_request(self, *_: Any) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _multipart(fields: Mapping[str, Any], files: Mapping[str, Any]) -> tuple[bytes, str]:
|
|
32
|
+
boundary = f"----neptls-{random.randrange(10**12):012d}"
|
|
33
|
+
buffer = io.BytesIO()
|
|
34
|
+
for name, value in fields.items():
|
|
35
|
+
buffer.write(f"--{boundary}\r\n".encode())
|
|
36
|
+
buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
|
37
|
+
buffer.write(str(value).encode())
|
|
38
|
+
buffer.write(b"\r\n")
|
|
39
|
+
for name, value in files.items():
|
|
40
|
+
filename, content, content_type = name, value, None
|
|
41
|
+
if isinstance(value, tuple):
|
|
42
|
+
filename, content = value[0], value[1]
|
|
43
|
+
content_type = value[2] if len(value) > 2 else None
|
|
44
|
+
if hasattr(content, "read"):
|
|
45
|
+
content = content.read()
|
|
46
|
+
elif isinstance(content, (str, Path)) and Path(content).exists():
|
|
47
|
+
path = Path(content)
|
|
48
|
+
filename, content = filename or path.name, path.read_bytes()
|
|
49
|
+
elif isinstance(content, str):
|
|
50
|
+
content = content.encode()
|
|
51
|
+
content_type = content_type or mimetypes.guess_type(str(filename))[0] or "application/octet-stream"
|
|
52
|
+
buffer.write(f"--{boundary}\r\n".encode())
|
|
53
|
+
buffer.write(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode())
|
|
54
|
+
buffer.write(f"Content-Type: {content_type}\r\n\r\n".encode())
|
|
55
|
+
buffer.write(content)
|
|
56
|
+
buffer.write(b"\r\n")
|
|
57
|
+
buffer.write(f"--{boundary}--\r\n".encode())
|
|
58
|
+
return buffer.getvalue(), f"multipart/form-data; boundary={boundary}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Client:
|
|
62
|
+
"""Reusable HTTP client with cookies, connection-aware opener, and retries."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
profile: str | BrowserProfile | None = None,
|
|
68
|
+
proxy: str | None = None,
|
|
69
|
+
headers: Mapping[str, str] | None = None,
|
|
70
|
+
cookies: Mapping[str, str] | None = None,
|
|
71
|
+
timeout: float = 30.0,
|
|
72
|
+
verify: bool = True,
|
|
73
|
+
follow_redirects: bool = True,
|
|
74
|
+
retries: int = 0,
|
|
75
|
+
backoff_factor: float = 0.2,
|
|
76
|
+
) -> None:
|
|
77
|
+
self.profile = get_profile(profile)
|
|
78
|
+
self.timeout = timeout
|
|
79
|
+
self.follow_redirects = follow_redirects
|
|
80
|
+
self.retries = max(0, retries)
|
|
81
|
+
self.backoff_factor = max(0.0, backoff_factor)
|
|
82
|
+
self.cookies = CookieJar()
|
|
83
|
+
self._headers = dict(self.profile.headers if self.profile else {})
|
|
84
|
+
if self.profile:
|
|
85
|
+
self._headers.setdefault("User-Agent", self.profile.user_agent)
|
|
86
|
+
if headers:
|
|
87
|
+
self._headers.update(headers)
|
|
88
|
+
if cookies:
|
|
89
|
+
for name, value in cookies.items():
|
|
90
|
+
self.cookies.set_cookie(
|
|
91
|
+
__import__("http.cookiejar", fromlist=["Cookie"]).Cookie(
|
|
92
|
+
version=0,
|
|
93
|
+
name=name,
|
|
94
|
+
value=value,
|
|
95
|
+
port=None,
|
|
96
|
+
port_specified=False,
|
|
97
|
+
domain="",
|
|
98
|
+
domain_specified=False,
|
|
99
|
+
domain_initial_dot=False,
|
|
100
|
+
path="/",
|
|
101
|
+
path_specified=True,
|
|
102
|
+
secure=False,
|
|
103
|
+
expires=None,
|
|
104
|
+
discard=True,
|
|
105
|
+
comment=None,
|
|
106
|
+
comment_url=None,
|
|
107
|
+
rest={},
|
|
108
|
+
rfc2109=False,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
if self.profile:
|
|
112
|
+
tls_config = self.profile.tls
|
|
113
|
+
else:
|
|
114
|
+
tls_config = TLSConfig(verify=verify)
|
|
115
|
+
if not verify:
|
|
116
|
+
tls_config = TLSConfig(**{**tls_config.to_dict(), "verify": False})
|
|
117
|
+
handlers: list[Any] = [
|
|
118
|
+
urllib.request.HTTPCookieProcessor(self.cookies),
|
|
119
|
+
urllib.request.HTTPSHandler(context=tls_config.ssl_context()),
|
|
120
|
+
]
|
|
121
|
+
if proxy:
|
|
122
|
+
handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
|
|
123
|
+
if not follow_redirects:
|
|
124
|
+
handlers.append(_NoRedirect())
|
|
125
|
+
self._opener = urllib.request.build_opener(*handlers)
|
|
126
|
+
|
|
127
|
+
def _url(self, url: str, params: Mapping[str, Any] | None) -> str:
|
|
128
|
+
if not params:
|
|
129
|
+
return url
|
|
130
|
+
parsed = urllib.parse.urlsplit(url)
|
|
131
|
+
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
132
|
+
query.extend((key, str(value)) for key, value in params.items())
|
|
133
|
+
return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
|
|
134
|
+
|
|
135
|
+
def request(
|
|
136
|
+
self,
|
|
137
|
+
method: str,
|
|
138
|
+
url: str,
|
|
139
|
+
*,
|
|
140
|
+
params: Mapping[str, Any] | None = None,
|
|
141
|
+
headers: Mapping[str, str] | None = None,
|
|
142
|
+
data: bytes | str | None = None,
|
|
143
|
+
json: Any = None,
|
|
144
|
+
form: Mapping[str, Any] | None = None,
|
|
145
|
+
files: Mapping[str, Any] | None = None,
|
|
146
|
+
timeout: float | None = None,
|
|
147
|
+
stream: bool = False,
|
|
148
|
+
auth: AuthHook | None = None,
|
|
149
|
+
) -> Response:
|
|
150
|
+
url = self._url(url, params)
|
|
151
|
+
request_headers = dict(self._headers)
|
|
152
|
+
if headers:
|
|
153
|
+
request_headers.update(headers)
|
|
154
|
+
body: bytes | None = None
|
|
155
|
+
if json is not None:
|
|
156
|
+
body = json_module.dumps(json).encode()
|
|
157
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
158
|
+
elif files:
|
|
159
|
+
body, content_type = _multipart(form or {}, files)
|
|
160
|
+
request_headers.setdefault("Content-Type", content_type)
|
|
161
|
+
elif form is not None:
|
|
162
|
+
body = urllib.parse.urlencode(form).encode()
|
|
163
|
+
request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
164
|
+
elif data is not None:
|
|
165
|
+
body = data.encode() if isinstance(data, str) else data
|
|
166
|
+
request = urllib.request.Request(url, data=body, headers=request_headers, method=method.upper())
|
|
167
|
+
if auth:
|
|
168
|
+
auth(request)
|
|
169
|
+
last_error: Exception | None = None
|
|
170
|
+
retry_statuses = {429, 500, 502, 503, 504}
|
|
171
|
+
for attempt in range(self.retries + 1):
|
|
172
|
+
started = time.perf_counter()
|
|
173
|
+
try:
|
|
174
|
+
raw = self._opener.open(request, timeout=timeout or self.timeout)
|
|
175
|
+
response = Response(
|
|
176
|
+
status_code=raw.status,
|
|
177
|
+
reason=raw.reason or "",
|
|
178
|
+
url=raw.geturl(),
|
|
179
|
+
headers=dict(raw.headers.items()),
|
|
180
|
+
raw=raw if stream else None,
|
|
181
|
+
content=b"" if stream else raw.read(),
|
|
182
|
+
request_method=method.upper(),
|
|
183
|
+
timing=RequestTiming(started, time.perf_counter(), first_byte=time.perf_counter()),
|
|
184
|
+
)
|
|
185
|
+
if not stream:
|
|
186
|
+
raw.close()
|
|
187
|
+
if response.status_code in retry_statuses and attempt < self.retries:
|
|
188
|
+
time.sleep(self.backoff_factor * (2**attempt))
|
|
189
|
+
continue
|
|
190
|
+
return response
|
|
191
|
+
except urllib.error.HTTPError as exc:
|
|
192
|
+
response = Response(
|
|
193
|
+
status_code=exc.code,
|
|
194
|
+
reason=exc.reason or "",
|
|
195
|
+
url=exc.geturl(),
|
|
196
|
+
headers=dict(exc.headers.items()) if exc.headers else {},
|
|
197
|
+
raw=exc if stream else None,
|
|
198
|
+
content=b"" if stream else exc.read(),
|
|
199
|
+
request_method=method.upper(),
|
|
200
|
+
timing=RequestTiming(started, time.perf_counter(), first_byte=time.perf_counter()),
|
|
201
|
+
)
|
|
202
|
+
if not stream:
|
|
203
|
+
exc.close()
|
|
204
|
+
if exc.code in retry_statuses and attempt < self.retries:
|
|
205
|
+
time.sleep(self.backoff_factor * (2**attempt))
|
|
206
|
+
continue
|
|
207
|
+
return response
|
|
208
|
+
except TimeoutError as exc:
|
|
209
|
+
last_error = exc
|
|
210
|
+
break
|
|
211
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
212
|
+
last_error = exc
|
|
213
|
+
if attempt < self.retries:
|
|
214
|
+
time.sleep(self.backoff_factor * (2**attempt))
|
|
215
|
+
continue
|
|
216
|
+
break
|
|
217
|
+
if isinstance(last_error, TimeoutError):
|
|
218
|
+
raise TimeoutError(f"request timed out: {url}") from last_error
|
|
219
|
+
raise RequestError(f"request failed: {url}: {last_error}") from last_error
|
|
220
|
+
|
|
221
|
+
def get(self, url: str, **kwargs: Any) -> Response:
|
|
222
|
+
return self.request("GET", url, **kwargs)
|
|
223
|
+
|
|
224
|
+
def post(self, url: str, **kwargs: Any) -> Response:
|
|
225
|
+
return self.request("POST", url, **kwargs)
|
|
226
|
+
|
|
227
|
+
def put(self, url: str, **kwargs: Any) -> Response:
|
|
228
|
+
return self.request("PUT", url, **kwargs)
|
|
229
|
+
|
|
230
|
+
def patch(self, url: str, **kwargs: Any) -> Response:
|
|
231
|
+
return self.request("PATCH", url, **kwargs)
|
|
232
|
+
|
|
233
|
+
def delete(self, url: str, **kwargs: Any) -> Response:
|
|
234
|
+
return self.request("DELETE", url, **kwargs)
|
|
235
|
+
|
|
236
|
+
def head(self, url: str, **kwargs: Any) -> Response:
|
|
237
|
+
return self.request("HEAD", url, **kwargs)
|
|
238
|
+
|
|
239
|
+
def options(self, url: str, **kwargs: Any) -> Response:
|
|
240
|
+
return self.request("OPTIONS", url, **kwargs)
|
|
241
|
+
|
|
242
|
+
def fingerprint(self) -> TLSFingerprint:
|
|
243
|
+
return TLSFingerprint.from_config(self.profile.tls if self.profile else TLSConfig())
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def __enter__(self) -> "Client":
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def __exit__(self, *_: object) -> None:
|
|
252
|
+
self.close()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def request(method: str, url: str, **kwargs: Any) -> Response:
|
|
256
|
+
with Client() as client:
|
|
257
|
+
return client.request(method, url, **kwargs)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get(url: str, **kwargs: Any) -> Response:
|
|
261
|
+
return request("GET", url, **kwargs)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def post(url: str, **kwargs: Any) -> Response:
|
|
265
|
+
return request("POST", url, **kwargs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def put(url: str, **kwargs: Any) -> Response:
|
|
269
|
+
return request("PUT", url, **kwargs)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def patch(url: str, **kwargs: Any) -> Response:
|
|
273
|
+
return request("PATCH", url, **kwargs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def delete(url: str, **kwargs: Any) -> Response:
|
|
277
|
+
return request("DELETE", url, **kwargs)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def head(url: str, **kwargs: Any) -> Response:
|
|
281
|
+
return request("HEAD", url, **kwargs)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def options(url: str, **kwargs: Any) -> Response:
|
|
285
|
+
return request("OPTIONS", url, **kwargs)
|
neptls/crypto.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Small, standard-library protocol encoding and hashing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote, unquote
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _bytes(value: str | bytes | bytearray) -> bytes:
|
|
14
|
+
return value.encode() if isinstance(value, str) else bytes(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sha256(value: str | bytes | bytearray) -> str:
|
|
18
|
+
return hashlib.sha256(_bytes(value)).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sha512(value: str | bytes | bytearray) -> str:
|
|
22
|
+
return hashlib.sha512(_bytes(value)).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def blake2(value: str | bytes | bytearray, *, digest_size: int = 32) -> str:
|
|
26
|
+
return hashlib.blake2b(_bytes(value), digest_size=digest_size).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def md5(value: str | bytes | bytearray) -> str:
|
|
30
|
+
return hashlib.md5(_bytes(value), usedforsecurity=False).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def b64encode(value: str | bytes | bytearray) -> str:
|
|
34
|
+
return base64.b64encode(_bytes(value)).decode("ascii")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def b64decode(value: str | bytes) -> bytes:
|
|
38
|
+
return base64.b64decode(_bytes(value))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hex_encode(value: str | bytes | bytearray) -> str:
|
|
42
|
+
return _bytes(value).hex()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hex_decode(value: str) -> bytes:
|
|
46
|
+
return bytes.fromhex(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def url_encode(value: str) -> str:
|
|
50
|
+
return quote(value, safe="")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def url_decode(value: str) -> str:
|
|
54
|
+
return unquote(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def json_encode(value: Mapping[str, Any] | list[Any]) -> str:
|
|
58
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def json_decode(value: str | bytes) -> Any:
|
|
62
|
+
return json.loads(value)
|
neptls/diagnostics.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read-only DNS, TCP, TLS, and HTTP diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import ssl
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from .client import Client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def inspect(url: str, *, timeout: float = 10.0) -> dict[str, Any]:
|
|
15
|
+
parsed = urlparse(url)
|
|
16
|
+
if not parsed.hostname:
|
|
17
|
+
raise ValueError("URL must include a hostname")
|
|
18
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
19
|
+
result: dict[str, Any] = {
|
|
20
|
+
"url": url,
|
|
21
|
+
"hostname": parsed.hostname,
|
|
22
|
+
"port": port,
|
|
23
|
+
"scheme": parsed.scheme,
|
|
24
|
+
"dns": {},
|
|
25
|
+
"tcp": {},
|
|
26
|
+
"tls": {},
|
|
27
|
+
"http": {},
|
|
28
|
+
}
|
|
29
|
+
dns_started = time.perf_counter()
|
|
30
|
+
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
|
|
31
|
+
result["dns"] = {
|
|
32
|
+
"duration": time.perf_counter() - dns_started,
|
|
33
|
+
"addresses": sorted({item[4][0] for item in addresses}),
|
|
34
|
+
}
|
|
35
|
+
tcp_started = time.perf_counter()
|
|
36
|
+
with socket.create_connection((parsed.hostname, port), timeout=timeout) as raw:
|
|
37
|
+
result["tcp"] = {"duration": time.perf_counter() - tcp_started, "connected": True}
|
|
38
|
+
if parsed.scheme == "https":
|
|
39
|
+
context = ssl.create_default_context()
|
|
40
|
+
tls_started = time.perf_counter()
|
|
41
|
+
with context.wrap_socket(raw, server_hostname=parsed.hostname) as secure:
|
|
42
|
+
result["tls"] = {
|
|
43
|
+
"duration": time.perf_counter() - tls_started,
|
|
44
|
+
"version": secure.version(),
|
|
45
|
+
"cipher": secure.cipher()[0] if secure.cipher() else None,
|
|
46
|
+
"alpn": secure.selected_alpn_protocol(),
|
|
47
|
+
"peer_certificate": secure.getpeercert(),
|
|
48
|
+
}
|
|
49
|
+
response = Client(timeout=timeout, retries=0).head(url)
|
|
50
|
+
result["http"] = {
|
|
51
|
+
"status_code": response.status_code,
|
|
52
|
+
"headers": response.headers,
|
|
53
|
+
"http_version": response.headers.get("x-http-version", "unknown"),
|
|
54
|
+
"duration": response.timing.total if response.timing else None,
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
diagnose = inspect
|
neptls/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""NepTLS exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NepTLSError(Exception):
|
|
5
|
+
"""Base class for all NepTLS errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestError(NepTLSError):
|
|
9
|
+
"""A network request could not be completed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeoutError(RequestError):
|
|
13
|
+
"""A request exceeded its configured timeout."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPStatusError(RequestError):
|
|
17
|
+
"""The server returned an unsuccessful HTTP status."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, response: object) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.response = response
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidProfileError(NepTLSError):
|
|
25
|
+
"""A browser or TLS profile is invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PowError(NepTLSError):
|
|
29
|
+
"""A proof-of-work challenge is invalid or unsolved."""
|