f-layer 2.0.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.
- f_layer-2.0.0.dist-info/METADATA +232 -0
- f_layer-2.0.0.dist-info/RECORD +45 -0
- f_layer-2.0.0.dist-info/WHEEL +4 -0
- f_layer-2.0.0.dist-info/entry_points.txt +2 -0
- f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
- flayer/__init__.py +5 -0
- flayer/__main__.py +247 -0
- flayer/_vpn_guest.py +130 -0
- flayer/core/__init__.py +1 -0
- flayer/core/config.py +198 -0
- flayer/core/contracts.py +114 -0
- flayer/core/lifecycle.py +767 -0
- flayer/core/state.py +351 -0
- flayer/diagnostics/__init__.py +20 -0
- flayer/diagnostics/benchmark.py +272 -0
- flayer/diagnostics/contracts.py +167 -0
- flayer/diagnostics/health.py +154 -0
- flayer/diagnostics/reporting.py +24 -0
- flayer/extensions/__init__.py +25 -0
- flayer/extensions/builtins.py +165 -0
- flayer/extensions/contracts.py +196 -0
- flayer/extensions/discovery.py +152 -0
- flayer/extensions/registry.py +347 -0
- flayer/guest.py +270 -0
- flayer/integrations/__init__.py +23 -0
- flayer/integrations/adapters.py +92 -0
- flayer/integrations/contracts.py +241 -0
- flayer/integrations/wire.py +273 -0
- flayer/profiles/__init__.py +33 -0
- flayer/profiles/_amnezia_export.py +176 -0
- flayer/profiles/_vless_guest.py +432 -0
- flayer/profiles/amneziawg.py +555 -0
- flayer/profiles/artifacts.py +609 -0
- flayer/profiles/gateway.py +729 -0
- flayer/profiles/static_site.py +596 -0
- flayer/profiles/vless.py +422 -0
- flayer/profiles/vpn.py +524 -0
- flayer/providers/__init__.py +1 -0
- flayer/providers/contracts.py +207 -0
- flayer/providers/lifecycle.py +177 -0
- flayer/providers/yandex.py +387 -0
- flayer/providers/yandex_lifecycle.py +781 -0
- flayer/py.typed +0 -0
- flayer/vpn_export.py +72 -0
- flayer/vpn_project.py +828 -0
flayer/core/state.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Minimal owned-resource snapshots with strict reads and atomic local persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Iterator, Mapping
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO
|
|
14
|
+
|
|
15
|
+
from .contracts import (
|
|
16
|
+
SCHEMA_VERSION,
|
|
17
|
+
ContractError,
|
|
18
|
+
ParseIdentity,
|
|
19
|
+
RequireTable,
|
|
20
|
+
StackIdentity,
|
|
21
|
+
ValidateFields,
|
|
22
|
+
ValidateIdentifier,
|
|
23
|
+
ValidateName,
|
|
24
|
+
ValidateSchemaVersion,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
MAX_JSON_BYTES = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StateError(ContractError):
|
|
31
|
+
"""Owned state is malformed, mismatched, busy, or cannot be persisted safely."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ResourceState:
|
|
36
|
+
"""Minimum provider locator for a resource recorded by one managed stack."""
|
|
37
|
+
|
|
38
|
+
logical_id: str
|
|
39
|
+
kind: str
|
|
40
|
+
resource_id: str
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Keep persisted locators explicit and free of arbitrary provider payloads."""
|
|
44
|
+
|
|
45
|
+
ValidateName(self.logical_id, "resources.logical_id")
|
|
46
|
+
ValidateName(self.kind, "resources.kind")
|
|
47
|
+
ValidateIdentifier(self.resource_id, "resources.resource_id")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class StackState:
|
|
52
|
+
"""Immutable resource inventory scoped to a complete ownership identity."""
|
|
53
|
+
|
|
54
|
+
identity: StackIdentity
|
|
55
|
+
resources: tuple[ResourceState, ...] = ()
|
|
56
|
+
schema_version: int = SCHEMA_VERSION
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
"""Reject duplicate locators and invalid direct model construction."""
|
|
60
|
+
|
|
61
|
+
ValidateSchemaVersion(self.schema_version)
|
|
62
|
+
|
|
63
|
+
if not isinstance(self.identity, StackIdentity):
|
|
64
|
+
raise ContractError("identity must be a StackIdentity")
|
|
65
|
+
|
|
66
|
+
if not isinstance(self.resources, tuple) or any(
|
|
67
|
+
not isinstance(resource, ResourceState) for resource in self.resources
|
|
68
|
+
):
|
|
69
|
+
raise ContractError("resources must be a tuple of ResourceState values")
|
|
70
|
+
|
|
71
|
+
if len({resource.logical_id for resource in self.resources}) != len(self.resources):
|
|
72
|
+
raise ContractError("resources.logical_id values must be unique")
|
|
73
|
+
|
|
74
|
+
if len({(resource.kind, resource.resource_id) for resource in self.resources}) != len(
|
|
75
|
+
self.resources
|
|
76
|
+
):
|
|
77
|
+
raise ContractError("resources must not contain duplicate provider locators")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _RequireIdentity(actual: StackIdentity, expected: StackIdentity) -> None:
|
|
81
|
+
"""Block all state operations when any ownership or provider-scope field differs."""
|
|
82
|
+
|
|
83
|
+
if not isinstance(expected, StackIdentity) or actual != expected:
|
|
84
|
+
raise StateError("State identity does not match the expected ownership boundary")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ParseState(data: Mapping[str, object], expected_identity: StackIdentity) -> StackState:
|
|
88
|
+
"""Decode a versioned snapshot and require explicit caller ownership expectations."""
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
ValidateFields(
|
|
92
|
+
data, frozenset({"schema_version", "identity", "resources"}), frozenset(), "state"
|
|
93
|
+
)
|
|
94
|
+
ValidateSchemaVersion(data["schema_version"])
|
|
95
|
+
identity = ParseIdentity(data["identity"])
|
|
96
|
+
_RequireIdentity(identity, expected_identity)
|
|
97
|
+
raw_resources = data["resources"]
|
|
98
|
+
|
|
99
|
+
if not isinstance(raw_resources, list):
|
|
100
|
+
raise ContractError("resources must be an array of tables")
|
|
101
|
+
|
|
102
|
+
resources: list[ResourceState] = []
|
|
103
|
+
|
|
104
|
+
for item in raw_resources:
|
|
105
|
+
table = RequireTable(item, "resources")
|
|
106
|
+
ValidateFields(
|
|
107
|
+
table,
|
|
108
|
+
frozenset({"logical_id", "kind", "resource_id"}),
|
|
109
|
+
frozenset(),
|
|
110
|
+
"resources",
|
|
111
|
+
)
|
|
112
|
+
resources.append(
|
|
113
|
+
ResourceState(
|
|
114
|
+
logical_id=ValidateName(table["logical_id"], "resources.logical_id"),
|
|
115
|
+
kind=ValidateName(table["kind"], "resources.kind"),
|
|
116
|
+
resource_id=ValidateIdentifier(table["resource_id"], "resources.resource_id"),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return StackState(identity=identity, resources=tuple(resources))
|
|
121
|
+
|
|
122
|
+
except ContractError as error:
|
|
123
|
+
raise StateError(str(error)) from error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _UniqueObject(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
127
|
+
"""Reject duplicate JSON fields instead of silently accepting the last value."""
|
|
128
|
+
|
|
129
|
+
result: dict[str, object] = {}
|
|
130
|
+
|
|
131
|
+
for key, value in pairs:
|
|
132
|
+
if key in result:
|
|
133
|
+
raise StateError("State JSON contains duplicate fields")
|
|
134
|
+
|
|
135
|
+
result[key] = value
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ValidatePath(path: Path) -> None:
|
|
141
|
+
"""Reject traversal, symlinks, and nonregular targets within caller-owned storage."""
|
|
142
|
+
|
|
143
|
+
if not path.name or ".." in path.parts:
|
|
144
|
+
raise StateError("State path must name a file without parent traversal")
|
|
145
|
+
|
|
146
|
+
for candidate in (path, *path.parents):
|
|
147
|
+
if candidate.is_symlink():
|
|
148
|
+
raise StateError("State path must not contain symlinks")
|
|
149
|
+
|
|
150
|
+
if path.exists() and not path.is_file():
|
|
151
|
+
raise StateError("State path must refer to a regular file")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _OpenTextStream(descriptor: int, mode: str) -> IO[str]:
|
|
155
|
+
"""Transfer descriptor ownership to a stream or close it when the transfer fails."""
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
return os.fdopen(descriptor, mode, encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
except BaseException:
|
|
161
|
+
os.close(descriptor)
|
|
162
|
+
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _ReadOwnedJson(path: Path) -> object:
|
|
167
|
+
"""Read bounded regular JSON without blocking on a post-validation FIFO substitution."""
|
|
168
|
+
|
|
169
|
+
opened_descriptor = os.open(
|
|
170
|
+
path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
171
|
+
)
|
|
172
|
+
descriptor: int | None = opened_descriptor
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
opened = os.fstat(opened_descriptor)
|
|
176
|
+
|
|
177
|
+
if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_JSON_BYTES:
|
|
178
|
+
raise StateError("Owned JSON must be a bounded regular file")
|
|
179
|
+
|
|
180
|
+
stream_descriptor = opened_descriptor
|
|
181
|
+
descriptor = None
|
|
182
|
+
|
|
183
|
+
with _OpenTextStream(stream_descriptor, "r") as stream:
|
|
184
|
+
text = stream.read(MAX_JSON_BYTES + 1)
|
|
185
|
+
|
|
186
|
+
if len(text.encode("utf-8")) > MAX_JSON_BYTES:
|
|
187
|
+
raise StateError("Owned JSON exceeds its bounded read limit")
|
|
188
|
+
|
|
189
|
+
return json.loads(text, object_pairs_hook=_UniqueObject)
|
|
190
|
+
|
|
191
|
+
except FileNotFoundError as error:
|
|
192
|
+
raise StateError("Unable to read an opened owned JSON file") from error
|
|
193
|
+
|
|
194
|
+
finally:
|
|
195
|
+
if descriptor is not None:
|
|
196
|
+
os.close(descriptor)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def LoadState(path: str | Path, expected_identity: StackIdentity) -> StackState | None:
|
|
200
|
+
"""Return a strictly owned snapshot, or None only when the file is absent."""
|
|
201
|
+
|
|
202
|
+
state_path = Path(path)
|
|
203
|
+
|
|
204
|
+
if not isinstance(expected_identity, StackIdentity):
|
|
205
|
+
raise StateError("expected_identity must be a StackIdentity")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
_ValidatePath(state_path)
|
|
209
|
+
data = _ReadOwnedJson(state_path)
|
|
210
|
+
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
except StateError:
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
except (OSError, ValueError, RecursionError) as error:
|
|
218
|
+
raise StateError("Unable to read a valid state snapshot") from error
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
return ParseState(RequireTable(data, "state"), expected_identity)
|
|
222
|
+
|
|
223
|
+
except ContractError as error:
|
|
224
|
+
raise StateError(str(error)) from error
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@contextmanager
|
|
228
|
+
def _StateLock(path: Path) -> Iterator[None]:
|
|
229
|
+
"""Serialize cooperating writers and leave crash locks for explicit operator recovery."""
|
|
230
|
+
|
|
231
|
+
descriptor: int | None = None
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
_ValidatePath(path)
|
|
235
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
236
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
238
|
+
os.close(descriptor)
|
|
239
|
+
descriptor = None
|
|
240
|
+
|
|
241
|
+
except OSError as error:
|
|
242
|
+
if descriptor is not None:
|
|
243
|
+
try:
|
|
244
|
+
os.close(descriptor)
|
|
245
|
+
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
raise StateError("State storage is unavailable or another writer holds its lock") from error
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
yield
|
|
253
|
+
|
|
254
|
+
finally:
|
|
255
|
+
try:
|
|
256
|
+
lock_path.unlink()
|
|
257
|
+
|
|
258
|
+
except OSError as error:
|
|
259
|
+
raise StateError("Unable to release the state writer lock") from error
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _SyncDirectory(path: Path) -> None:
|
|
263
|
+
"""Persist directory updates on platforms with a usable directory fsync operation."""
|
|
264
|
+
|
|
265
|
+
if os.name != "posix":
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
os.fsync(descriptor)
|
|
272
|
+
|
|
273
|
+
finally:
|
|
274
|
+
os.close(descriptor)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def SaveState(path: str | Path, state: StackState, expected_identity: StackIdentity) -> None:
|
|
278
|
+
"""Atomically save owned state; never replace a foreign or malformed existing snapshot."""
|
|
279
|
+
|
|
280
|
+
if not isinstance(state, StackState):
|
|
281
|
+
raise StateError("state must be a StackState")
|
|
282
|
+
|
|
283
|
+
_RequireIdentity(state.identity, expected_identity)
|
|
284
|
+
serialized = json.dumps(asdict(state), indent=2, sort_keys=True) + "\n"
|
|
285
|
+
|
|
286
|
+
if len(serialized.encode("utf-8")) > MAX_JSON_BYTES:
|
|
287
|
+
raise StateError("Owned state snapshot exceeds its bounded JSON storage limit")
|
|
288
|
+
|
|
289
|
+
state_path = Path(path)
|
|
290
|
+
temporary_path: Path | None = None
|
|
291
|
+
|
|
292
|
+
with _StateLock(state_path):
|
|
293
|
+
LoadState(state_path, expected_identity)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
297
|
+
prefix=f".{state_path.name}.", suffix=".tmp", dir=state_path.parent
|
|
298
|
+
)
|
|
299
|
+
temporary_path = Path(temporary_name)
|
|
300
|
+
|
|
301
|
+
with _OpenTextStream(descriptor, "w") as stream:
|
|
302
|
+
stream.write(serialized)
|
|
303
|
+
stream.flush()
|
|
304
|
+
os.fsync(stream.fileno())
|
|
305
|
+
|
|
306
|
+
os.replace(temporary_path, state_path)
|
|
307
|
+
temporary_path = None
|
|
308
|
+
_SyncDirectory(state_path.parent)
|
|
309
|
+
|
|
310
|
+
except OSError as error:
|
|
311
|
+
raise StateError(
|
|
312
|
+
"Unable to persist state; a completed replacement may be visible"
|
|
313
|
+
) from error
|
|
314
|
+
|
|
315
|
+
finally:
|
|
316
|
+
if temporary_path is not None:
|
|
317
|
+
try:
|
|
318
|
+
temporary_path.unlink(missing_ok=True)
|
|
319
|
+
|
|
320
|
+
except OSError as error:
|
|
321
|
+
raise StateError(
|
|
322
|
+
"Unable to clean temporary state; "
|
|
323
|
+
"incomplete local cleanup requires recovery"
|
|
324
|
+
) from error
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def RemoveState(path: str | Path, expected_identity: StackIdentity) -> bool:
|
|
328
|
+
"""Remove only a matching local snapshot; never delete infrastructure resources."""
|
|
329
|
+
|
|
330
|
+
state_path = Path(path)
|
|
331
|
+
|
|
332
|
+
if not isinstance(expected_identity, StackIdentity):
|
|
333
|
+
raise StateError("expected_identity must be a StackIdentity")
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
_ValidatePath(state_path)
|
|
337
|
+
|
|
338
|
+
if not state_path.parent.exists():
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
with _StateLock(state_path):
|
|
342
|
+
if LoadState(state_path, expected_identity) is None:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
state_path.unlink()
|
|
346
|
+
_SyncDirectory(state_path.parent)
|
|
347
|
+
|
|
348
|
+
except OSError as error:
|
|
349
|
+
raise StateError("Unable to remove the owned state snapshot") from error
|
|
350
|
+
|
|
351
|
+
return True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Safe diagnostic contracts with explicit provider and endpoint boundaries."""
|
|
2
|
+
|
|
3
|
+
from .benchmark import (
|
|
4
|
+
BenchmarkLimits,
|
|
5
|
+
EndpointMeasurement,
|
|
6
|
+
EndpointTransport,
|
|
7
|
+
MeasureEndpoint,
|
|
8
|
+
ProbeEndpoint,
|
|
9
|
+
RunBenchmark,
|
|
10
|
+
)
|
|
11
|
+
from .contracts import CheckResult, DiagnosticReport, DiagnosticStatus, Redact
|
|
12
|
+
from .health import EvaluateHealthReport, HealthProbe, ProviderStatus, RunChecks, RuntimeChecks
|
|
13
|
+
from .reporting import RenderJson, RenderText
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BenchmarkLimits", "CheckResult", "DiagnosticReport", "DiagnosticStatus",
|
|
17
|
+
"EndpointMeasurement", "EndpointTransport", "EvaluateHealthReport", "HealthProbe",
|
|
18
|
+
"MeasureEndpoint", "ProbeEndpoint", "ProviderStatus", "Redact", "RenderJson", "RenderText",
|
|
19
|
+
"RunBenchmark", "RunChecks", "RuntimeChecks",
|
|
20
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Bounded HTTP measurements against an explicitly authorized endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import math
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from multiprocessing.connection import Connection
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
from urllib.parse import SplitResult, urlsplit
|
|
13
|
+
|
|
14
|
+
from .contracts import CheckResult, DiagnosticStatus
|
|
15
|
+
|
|
16
|
+
MAX_TIMEOUT_SECONDS = 30.0
|
|
17
|
+
MAX_REQUEST_COUNT = 10
|
|
18
|
+
MAX_BYTE_LIMIT = 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BenchmarkLimits:
|
|
23
|
+
"""Hard per-request deadline, request count, and response payload limits."""
|
|
24
|
+
|
|
25
|
+
timeout_seconds: float = 3.0
|
|
26
|
+
count: int = 3
|
|
27
|
+
byte_limit: int = 64 * 1024
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""Reject invalid or unbounded work before starting a transport."""
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
isinstance(self.timeout_seconds, bool)
|
|
34
|
+
or not math.isfinite(self.timeout_seconds)
|
|
35
|
+
or not 0 < self.timeout_seconds <= MAX_TIMEOUT_SECONDS
|
|
36
|
+
or isinstance(self.count, bool)
|
|
37
|
+
or not isinstance(self.count, int)
|
|
38
|
+
or not 1 <= self.count <= MAX_REQUEST_COUNT
|
|
39
|
+
or isinstance(self.byte_limit, bool)
|
|
40
|
+
or not isinstance(self.byte_limit, int)
|
|
41
|
+
or not 1 <= self.byte_limit <= MAX_BYTE_LIMIT
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("Benchmark limits exceed the supported bounds")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class EndpointMeasurement:
|
|
48
|
+
"""Safe metrics exclude response content, credentials, and target identifiers."""
|
|
49
|
+
|
|
50
|
+
status: DiagnosticStatus
|
|
51
|
+
duration_seconds: float
|
|
52
|
+
received_bytes: int = 0
|
|
53
|
+
http_status: int | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EndpointTransport(Protocol):
|
|
57
|
+
"""A measurement implementation enforces supplied limits without emitting raw data."""
|
|
58
|
+
|
|
59
|
+
def __call__(
|
|
60
|
+
self, endpoint: str, timeout_seconds: float, byte_limit: int
|
|
61
|
+
) -> EndpointMeasurement:
|
|
62
|
+
"""Return one bounded HTTP measurement without exposing response payloads."""
|
|
63
|
+
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ValidateEndpoint(endpoint: str) -> SplitResult:
|
|
68
|
+
"""Allow explicit HTTP(S) URLs without credentials, query strings, or fragments."""
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
parsed = urlsplit(endpoint)
|
|
72
|
+
port = parsed.port
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
parsed.scheme not in ("http", "https")
|
|
76
|
+
or not parsed.hostname
|
|
77
|
+
or parsed.username is not None
|
|
78
|
+
or parsed.password is not None
|
|
79
|
+
or parsed.query
|
|
80
|
+
or parsed.fragment
|
|
81
|
+
or any(character.isspace() or ord(character) < 32 for character in endpoint)
|
|
82
|
+
or (port is not None and port < 1)
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("Invalid endpoint")
|
|
85
|
+
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise ValueError("Endpoint must be an HTTP(S) URL without credentials, query or fragment") \
|
|
88
|
+
from None
|
|
89
|
+
|
|
90
|
+
return parsed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def HttpMeasurement(endpoint: str, timeout_seconds: float, byte_limit: int) -> EndpointMeasurement:
|
|
94
|
+
"""Read a limited payload with TLS verification and no redirects or proxy discovery."""
|
|
95
|
+
|
|
96
|
+
parsed = ValidateEndpoint(endpoint)
|
|
97
|
+
connection_type = (
|
|
98
|
+
http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
|
99
|
+
)
|
|
100
|
+
connection = connection_type(str(parsed.hostname), port=parsed.port, timeout=timeout_seconds)
|
|
101
|
+
started = time.monotonic()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
connection.request(
|
|
105
|
+
"GET", parsed.path or "/",
|
|
106
|
+
headers={"Range": f"bytes=0-{byte_limit - 1}", "Connection": "close"},
|
|
107
|
+
)
|
|
108
|
+
response = connection.getresponse()
|
|
109
|
+
received_bytes = len(response.read(byte_limit))
|
|
110
|
+
status = DiagnosticStatus.OK if 200 <= response.status < 300 else DiagnosticStatus.FAILED
|
|
111
|
+
|
|
112
|
+
return EndpointMeasurement(status, time.monotonic() - started, received_bytes, response.status)
|
|
113
|
+
|
|
114
|
+
finally:
|
|
115
|
+
connection.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def EndpointWorker(
|
|
119
|
+
sender: Connection, endpoint: str, timeout_seconds: float, byte_limit: int
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Isolate network work so DNS, TLS, connect, and response reads share a hard deadline."""
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
measurement = HttpMeasurement(endpoint, timeout_seconds, byte_limit)
|
|
125
|
+
|
|
126
|
+
except Exception:
|
|
127
|
+
measurement = EndpointMeasurement(DiagnosticStatus.FAILED, 0.0)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
sender.send(measurement)
|
|
131
|
+
|
|
132
|
+
finally:
|
|
133
|
+
sender.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def MeasureEndpoint(endpoint: str, timeout_seconds: float, byte_limit: int) -> EndpointMeasurement:
|
|
137
|
+
"""Terminate isolated network work at the deadline, including a stalled DNS resolver."""
|
|
138
|
+
|
|
139
|
+
ValidateEndpoint(endpoint)
|
|
140
|
+
BenchmarkLimits(timeout_seconds=timeout_seconds, count=1, byte_limit=byte_limit)
|
|
141
|
+
context = multiprocessing.get_context("spawn")
|
|
142
|
+
receiver, sender = context.Pipe(duplex=False)
|
|
143
|
+
process = context.Process(
|
|
144
|
+
target=EndpointWorker, args=(sender, endpoint, timeout_seconds, byte_limit), daemon=True
|
|
145
|
+
)
|
|
146
|
+
started = time.monotonic()
|
|
147
|
+
launched = False
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
process.start()
|
|
151
|
+
launched = True
|
|
152
|
+
sender.close()
|
|
153
|
+
remaining = max(0.0, timeout_seconds - (time.monotonic() - started))
|
|
154
|
+
|
|
155
|
+
if receiver.poll(remaining):
|
|
156
|
+
measurement: EndpointMeasurement = receiver.recv()
|
|
157
|
+
|
|
158
|
+
return measurement
|
|
159
|
+
|
|
160
|
+
return EndpointMeasurement(DiagnosticStatus.FAILED, timeout_seconds)
|
|
161
|
+
|
|
162
|
+
except (OSError, EOFError, RuntimeError):
|
|
163
|
+
return EndpointMeasurement(DiagnosticStatus.FAILED, time.monotonic() - started)
|
|
164
|
+
|
|
165
|
+
finally:
|
|
166
|
+
sender.close()
|
|
167
|
+
receiver.close()
|
|
168
|
+
|
|
169
|
+
if launched:
|
|
170
|
+
if process.is_alive():
|
|
171
|
+
process.terminate()
|
|
172
|
+
|
|
173
|
+
process.join(timeout=1.0)
|
|
174
|
+
|
|
175
|
+
if process.is_alive():
|
|
176
|
+
process.kill()
|
|
177
|
+
process.join(timeout=1.0)
|
|
178
|
+
|
|
179
|
+
process.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def SafeMeasurement(
|
|
183
|
+
endpoint: str, limits: BenchmarkLimits, transport: EndpointTransport
|
|
184
|
+
) -> EndpointMeasurement:
|
|
185
|
+
"""Reject invalid adapter metrics and suppress all unstructured exception output."""
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
measurement = transport(endpoint, limits.timeout_seconds, limits.byte_limit)
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
not isinstance(measurement, EndpointMeasurement)
|
|
192
|
+
or not isinstance(measurement.status, DiagnosticStatus)
|
|
193
|
+
or isinstance(measurement.duration_seconds, bool)
|
|
194
|
+
or not math.isfinite(measurement.duration_seconds)
|
|
195
|
+
or not 0 <= measurement.duration_seconds <= limits.timeout_seconds
|
|
196
|
+
or isinstance(measurement.received_bytes, bool)
|
|
197
|
+
or not isinstance(measurement.received_bytes, int)
|
|
198
|
+
or not 0 <= measurement.received_bytes <= limits.byte_limit
|
|
199
|
+
or (
|
|
200
|
+
measurement.http_status is not None
|
|
201
|
+
and (
|
|
202
|
+
isinstance(measurement.http_status, bool)
|
|
203
|
+
or not isinstance(measurement.http_status, int)
|
|
204
|
+
or not 100 <= measurement.http_status <= 599
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
or (
|
|
208
|
+
measurement.status is DiagnosticStatus.OK
|
|
209
|
+
and (measurement.http_status is None or not 200 <= measurement.http_status < 300)
|
|
210
|
+
)
|
|
211
|
+
):
|
|
212
|
+
raise ValueError("Adapter returned invalid metrics")
|
|
213
|
+
|
|
214
|
+
return measurement
|
|
215
|
+
|
|
216
|
+
except Exception:
|
|
217
|
+
return EndpointMeasurement(DiagnosticStatus.FAILED, 0.0)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ProbeEndpoint(
|
|
221
|
+
endpoint: str, timeout_seconds: float = 3.0,
|
|
222
|
+
transport: EndpointTransport = MeasureEndpoint,
|
|
223
|
+
) -> CheckResult:
|
|
224
|
+
"""Check explicit HTTP reachability without pretending it validates guest hardening."""
|
|
225
|
+
|
|
226
|
+
ValidateEndpoint(endpoint)
|
|
227
|
+
limits = BenchmarkLimits(timeout_seconds=timeout_seconds, count=1, byte_limit=1)
|
|
228
|
+
measurement = SafeMeasurement(endpoint, limits, transport)
|
|
229
|
+
|
|
230
|
+
return CheckResult(
|
|
231
|
+
"endpoint", measurement.status,
|
|
232
|
+
"Endpoint answered successfully" if measurement.status is DiagnosticStatus.OK
|
|
233
|
+
else "Endpoint probe did not succeed",
|
|
234
|
+
{"duration_seconds": measurement.duration_seconds, "http_status": measurement.http_status},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def RunBenchmark(
|
|
239
|
+
endpoint: str, limits: BenchmarkLimits | None = None,
|
|
240
|
+
transport: EndpointTransport = MeasureEndpoint,
|
|
241
|
+
) -> tuple[CheckResult, ...]:
|
|
242
|
+
"""Measure a fixed count of bounded downloads without inferring upload or tunnel speed."""
|
|
243
|
+
|
|
244
|
+
ValidateEndpoint(endpoint)
|
|
245
|
+
resolved_limits = limits or BenchmarkLimits()
|
|
246
|
+
checks: list[CheckResult] = []
|
|
247
|
+
|
|
248
|
+
for index in range(resolved_limits.count):
|
|
249
|
+
measurement = SafeMeasurement(endpoint, resolved_limits, transport)
|
|
250
|
+
status = measurement.status
|
|
251
|
+
|
|
252
|
+
if status is DiagnosticStatus.OK and measurement.received_bytes == 0:
|
|
253
|
+
status = DiagnosticStatus.FAILED
|
|
254
|
+
|
|
255
|
+
throughput = (
|
|
256
|
+
measurement.received_bytes * 8 / measurement.duration_seconds / 1_000_000
|
|
257
|
+
if measurement.duration_seconds > 0 else None
|
|
258
|
+
)
|
|
259
|
+
checks.append(CheckResult(
|
|
260
|
+
f"http-download-{index + 1}", status,
|
|
261
|
+
"Bounded HTTP download measured" if status is DiagnosticStatus.OK
|
|
262
|
+
else "Bounded HTTP download did not succeed",
|
|
263
|
+
{
|
|
264
|
+
"received_bytes": measurement.received_bytes,
|
|
265
|
+
"duration_seconds": measurement.duration_seconds,
|
|
266
|
+
"throughput_mbps": throughput,
|
|
267
|
+
"http_status": measurement.http_status,
|
|
268
|
+
"byte_limit": resolved_limits.byte_limit,
|
|
269
|
+
},
|
|
270
|
+
))
|
|
271
|
+
|
|
272
|
+
return tuple(checks)
|