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.
Files changed (45) hide show
  1. f_layer-2.0.0.dist-info/METADATA +232 -0
  2. f_layer-2.0.0.dist-info/RECORD +45 -0
  3. f_layer-2.0.0.dist-info/WHEEL +4 -0
  4. f_layer-2.0.0.dist-info/entry_points.txt +2 -0
  5. f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
  6. flayer/__init__.py +5 -0
  7. flayer/__main__.py +247 -0
  8. flayer/_vpn_guest.py +130 -0
  9. flayer/core/__init__.py +1 -0
  10. flayer/core/config.py +198 -0
  11. flayer/core/contracts.py +114 -0
  12. flayer/core/lifecycle.py +767 -0
  13. flayer/core/state.py +351 -0
  14. flayer/diagnostics/__init__.py +20 -0
  15. flayer/diagnostics/benchmark.py +272 -0
  16. flayer/diagnostics/contracts.py +167 -0
  17. flayer/diagnostics/health.py +154 -0
  18. flayer/diagnostics/reporting.py +24 -0
  19. flayer/extensions/__init__.py +25 -0
  20. flayer/extensions/builtins.py +165 -0
  21. flayer/extensions/contracts.py +196 -0
  22. flayer/extensions/discovery.py +152 -0
  23. flayer/extensions/registry.py +347 -0
  24. flayer/guest.py +270 -0
  25. flayer/integrations/__init__.py +23 -0
  26. flayer/integrations/adapters.py +92 -0
  27. flayer/integrations/contracts.py +241 -0
  28. flayer/integrations/wire.py +273 -0
  29. flayer/profiles/__init__.py +33 -0
  30. flayer/profiles/_amnezia_export.py +176 -0
  31. flayer/profiles/_vless_guest.py +432 -0
  32. flayer/profiles/amneziawg.py +555 -0
  33. flayer/profiles/artifacts.py +609 -0
  34. flayer/profiles/gateway.py +729 -0
  35. flayer/profiles/static_site.py +596 -0
  36. flayer/profiles/vless.py +422 -0
  37. flayer/profiles/vpn.py +524 -0
  38. flayer/providers/__init__.py +1 -0
  39. flayer/providers/contracts.py +207 -0
  40. flayer/providers/lifecycle.py +177 -0
  41. flayer/providers/yandex.py +387 -0
  42. flayer/providers/yandex_lifecycle.py +781 -0
  43. flayer/py.typed +0 -0
  44. flayer/vpn_export.py +72 -0
  45. flayer/vpn_project.py +828 -0
@@ -0,0 +1,273 @@
1
+ """Bounded strict JSON codecs validate representations without dispatch or authorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import cast
7
+
8
+ from flayer.core.contracts import ParseIdentity, RequireTable, StackIdentity, ValidateFields
9
+ from flayer.diagnostics.contracts import DiagnosticStatus
10
+
11
+ from .contracts import (
12
+ MAX_CHECKS,
13
+ MAX_WIRE_BYTES,
14
+ METRIC_BOUNDS,
15
+ RESOURCE_STATUSES,
16
+ CheckEvidence,
17
+ DiagnosticEvidence,
18
+ IntegrationError,
19
+ IntegrationOperation,
20
+ IntegrationRequest,
21
+ IntegrationResult,
22
+ LifecycleEvidence,
23
+ Metric,
24
+ RequireIdentity,
25
+ ResourceCount,
26
+ StateEvidence,
27
+ )
28
+
29
+ MAX_JSON_DEPTH = 8
30
+ MAX_JSON_VALUES = 4096
31
+ ENVELOPE_FIELDS = frozenset({
32
+ "schema_version", "envelope", "identity", "request_id", "operation",
33
+ })
34
+
35
+
36
+ def _UniqueObject(pairs: list[tuple[str, object]]) -> dict[str, object]:
37
+ """Reject duplicate fields at every object level without displaying their values."""
38
+
39
+ result: dict[str, object] = {}
40
+
41
+ for key, value in pairs:
42
+ if key in result:
43
+ raise IntegrationError("Integration JSON contains duplicate fields")
44
+
45
+ result[key] = value
46
+
47
+ return result
48
+
49
+
50
+ def _RejectConstant(value: str) -> object:
51
+ """Reject nonstandard JSON numeric tokens without returning rejected text."""
52
+
53
+ raise IntegrationError("Integration JSON requires finite standard numbers")
54
+
55
+
56
+ def _BoundStructure(value: object, depth: int, remaining: list[int]) -> None:
57
+ """Bound decoded nesting and total values before schema traversal."""
58
+
59
+ remaining[0] -= 1
60
+
61
+ if depth > MAX_JSON_DEPTH or remaining[0] < 0:
62
+ raise IntegrationError("Integration JSON exceeds its structural bounds")
63
+
64
+ if isinstance(value, dict):
65
+ for item in value.values():
66
+ _BoundStructure(item, depth + 1, remaining)
67
+
68
+ elif isinstance(value, list):
69
+ for item in value:
70
+ _BoundStructure(item, depth + 1, remaining)
71
+
72
+
73
+ def _Decode(payload: str) -> object:
74
+ """Read only bounded UTF-8 JSON strings and sanitize every decoding error."""
75
+
76
+ try:
77
+ if not isinstance(payload, str) or len(payload) > MAX_WIRE_BYTES:
78
+ raise IntegrationError("Integration JSON exceeds its byte limit")
79
+
80
+ if len(payload.encode("utf-8")) > MAX_WIRE_BYTES:
81
+ raise IntegrationError("Integration JSON exceeds its byte limit")
82
+
83
+ decoded: object = json.loads(
84
+ payload, object_pairs_hook=_UniqueObject, parse_constant=_RejectConstant,
85
+ )
86
+ _BoundStructure(decoded, 0, [MAX_JSON_VALUES])
87
+
88
+ return decoded
89
+
90
+ except (ValueError, RecursionError, UnicodeError):
91
+ raise IntegrationError("Unable to parse a valid bounded integration envelope") from None
92
+
93
+
94
+ def _Request(value: object, expected_identity: StackIdentity, envelope: str) -> IntegrationRequest:
95
+ """Bind decoded metadata to caller context with an exact envelope schema."""
96
+
97
+ table = RequireTable(value, "integration envelope")
98
+ extra_fields = frozenset({"evidence"}) if envelope == "result" else frozenset()
99
+ ValidateFields(table, ENVELOPE_FIELDS | extra_fields, frozenset(), "integration envelope")
100
+
101
+ if table["envelope"] != envelope:
102
+ raise IntegrationError("Integration envelope kind is unsupported")
103
+
104
+ identity = ParseIdentity(table["identity"])
105
+ RequireIdentity(identity, expected_identity)
106
+ request_id = table["request_id"]
107
+ operation = table["operation"]
108
+
109
+ if not isinstance(request_id, str) or not isinstance(operation, str):
110
+ raise IntegrationError("Integration correlation and operation require strings")
111
+
112
+ return IntegrationRequest(
113
+ identity, request_id, IntegrationOperation(operation), cast(int, table["schema_version"]),
114
+ )
115
+
116
+
117
+ def ParseRequest(payload: str, expected_identity: StackIdentity) -> IntegrationRequest:
118
+ """Validate untrusted read-only intent without invoking any operation or permission check."""
119
+
120
+ try:
121
+ return _Request(_Decode(payload), expected_identity, "request")
122
+
123
+ except ValueError:
124
+ raise IntegrationError("Unable to parse a valid owned integration request") from None
125
+
126
+
127
+ def _Array(value: object, limit: int) -> list[object]:
128
+ """Require bounded JSON arrays rather than accepting iterable or coerced values."""
129
+
130
+ if not isinstance(value, list) or len(value) > limit:
131
+ raise IntegrationError("Integration evidence requires a bounded array")
132
+
133
+ return value
134
+
135
+
136
+ def _ParseDiagnostic(value: object) -> DiagnosticEvidence:
137
+ """Validate allowlisted checks and a severity summary consistent with their outcomes."""
138
+
139
+ table = RequireTable(value, "diagnostic evidence")
140
+ ValidateFields(table, frozenset({"status", "checks"}), frozenset(), "diagnostic evidence")
141
+ checks: list[CheckEvidence] = []
142
+
143
+ for item in _Array(table["checks"], MAX_CHECKS):
144
+ check = RequireTable(item, "check evidence")
145
+ ValidateFields(check, frozenset({"status", "metrics"}), frozenset(), "check evidence")
146
+ metrics = RequireTable(check["metrics"], "check metrics")
147
+ ValidateFields(metrics, frozenset(), frozenset(METRIC_BOUNDS), "check metrics")
148
+ checks.append(CheckEvidence(DiagnosticStatus(check["status"]), tuple(
149
+ Metric(name, cast(int | float | None, metric))
150
+ for name, metric in sorted(metrics.items())
151
+ )))
152
+
153
+ evidence = DiagnosticEvidence(tuple(checks))
154
+
155
+ if table["status"] != evidence.Status.value:
156
+ raise IntegrationError("Integration diagnostic summary does not match its checks")
157
+
158
+ return evidence
159
+
160
+
161
+ def _ParseLifecycle(value: object) -> LifecycleEvidence:
162
+ """Decode only stable lifecycle outcomes and bounded observation category counts."""
163
+
164
+ table = RequireTable(value, "lifecycle evidence")
165
+ ValidateFields(table, frozenset({"action", "status", "recovery_required", "resources"}),
166
+ frozenset(), "lifecycle evidence")
167
+ resources: list[ResourceCount] = []
168
+
169
+ for item in _Array(table["resources"], len(RESOURCE_STATUSES)):
170
+ resource = RequireTable(item, "resource count")
171
+ ValidateFields(resource, frozenset({"status", "count"}), frozenset(), "resource count")
172
+ resources.append(ResourceCount(cast(str, resource["status"]), cast(int, resource["count"])))
173
+
174
+ return LifecycleEvidence(
175
+ cast(str, table["action"]), cast(str, table["status"]),
176
+ cast(bool, table["recovery_required"]), tuple(resources),
177
+ )
178
+
179
+
180
+ def ParseResult(payload: str, expected_request: IntegrationRequest) -> IntegrationResult:
181
+ """Bind an untrusted representation to its request without asserting authenticity."""
182
+
183
+ try:
184
+ if not isinstance(expected_request, IntegrationRequest):
185
+ raise IntegrationError("Integration result requires an originating request")
186
+
187
+ value = _Decode(payload)
188
+ request = _Request(value, expected_request.identity, "result")
189
+
190
+ if request != expected_request:
191
+ raise IntegrationError("Integration result does not match the originating request")
192
+
193
+ table = RequireTable(value, "integration result")
194
+
195
+ if request.operation is IntegrationOperation.STATE:
196
+ state = RequireTable(table["evidence"], "state evidence")
197
+ ValidateFields(state, frozenset({"resource_count"}), frozenset(), "state evidence")
198
+
199
+ return IntegrationResult(request, StateEvidence(cast(int, state["resource_count"])))
200
+
201
+ if request.operation is IntegrationOperation.LIFECYCLE_REPORT:
202
+ return IntegrationResult(request, _ParseLifecycle(table["evidence"]))
203
+
204
+ return IntegrationResult(request, _ParseDiagnostic(table["evidence"]))
205
+
206
+ except (ValueError, TypeError):
207
+ raise IntegrationError("Unable to parse a valid correlated integration result") from None
208
+
209
+
210
+ def _Metadata(request: IntegrationRequest, envelope: str) -> dict[str, object]:
211
+ """Represent validated non-secret routing metadata without consumer-specific fields."""
212
+
213
+ return {
214
+ "schema_version": request.schema_version, "envelope": envelope,
215
+ "identity": {
216
+ "project": request.identity.project, "stack": request.identity.stack,
217
+ "provider": request.identity.provider, "scope_id": request.identity.scope_id,
218
+ "owner_id": request.identity.owner_id,
219
+ },
220
+ "request_id": request.request_id,
221
+ "operation": request.operation.value,
222
+ }
223
+
224
+
225
+ def _Encode(value: dict[str, object]) -> str:
226
+ """Produce deterministic standard JSON within the same byte limit as parsing."""
227
+
228
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
229
+
230
+ if len(payload.encode("utf-8")) > MAX_WIRE_BYTES:
231
+ raise IntegrationError("Serialized integration envelope exceeds its byte limit")
232
+
233
+ return payload
234
+
235
+
236
+ def SerializeRequest(request: IntegrationRequest) -> str:
237
+ """Serialize validated observation intent without resolving targets or credentials."""
238
+
239
+ if not isinstance(request, IntegrationRequest):
240
+ raise IntegrationError("Integration serialization requires a validated request")
241
+
242
+ return _Encode(_Metadata(request, "request"))
243
+
244
+
245
+ def SerializeResult(result: IntegrationResult) -> str:
246
+ """Serialize only explicitly allowed evidence, never source objects or raw adapter data."""
247
+
248
+ if not isinstance(result, IntegrationResult):
249
+ raise IntegrationError("Integration serialization requires a validated result")
250
+
251
+ value = _Metadata(result.request, "result")
252
+ evidence = result.evidence
253
+
254
+ if isinstance(evidence, DiagnosticEvidence):
255
+ value["evidence"] = {
256
+ "status": evidence.Status.value,
257
+ "checks": [{"status": check.status.value,
258
+ "metrics": {metric.name: metric.value for metric in check.metrics}}
259
+ for check in evidence.checks],
260
+ }
261
+
262
+ elif isinstance(evidence, StateEvidence):
263
+ value["evidence"] = {"resource_count": evidence.resource_count}
264
+
265
+ else:
266
+ value["evidence"] = {
267
+ "action": evidence.action, "status": evidence.status,
268
+ "recovery_required": evidence.recovery_required,
269
+ "resources": [{"status": resource.status, "count": resource.count}
270
+ for resource in evidence.resources],
271
+ }
272
+
273
+ return _Encode(value)
@@ -0,0 +1,33 @@
1
+ """Explicit deployment profiles and owned generated artifacts."""
2
+
3
+ from .artifacts import (
4
+ Artifact,
5
+ ArtifactBundle,
6
+ ArtifactError,
7
+ BuildDeviceBundle,
8
+ RemoveArtifactBundle,
9
+ WriteArtifactBundle,
10
+ )
11
+ from .gateway import (
12
+ BuildServerBundle,
13
+ BuildSshDeviceBundle,
14
+ CompileGateway,
15
+ GatewayDevice,
16
+ GatewayError,
17
+ GatewayPlan,
18
+ GatewayPort,
19
+ GatewayProfile,
20
+ GatewayTarget,
21
+ GatewayTransport,
22
+ LoadGatewayProfile,
23
+ ParseGatewayProfile,
24
+ RenderPlan,
25
+ )
26
+
27
+ __all__ = [
28
+ "Artifact", "ArtifactBundle", "ArtifactError", "BuildDeviceBundle", "BuildServerBundle",
29
+ "BuildSshDeviceBundle", "GatewayDevice", "GatewayTarget", "GatewayTransport",
30
+ "CompileGateway", "GatewayError", "GatewayPlan", "GatewayPort", "GatewayProfile",
31
+ "LoadGatewayProfile", "ParseGatewayProfile", "RemoveArtifactBundle", "RenderPlan",
32
+ "WriteArtifactBundle",
33
+ ]
@@ -0,0 +1,176 @@
1
+ """Encode private AmneziaVPN connection profiles and native scanner QR frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import configparser
7
+ import importlib
8
+ import io
9
+ import ipaddress
10
+ import json
11
+ import struct
12
+ import zlib
13
+
14
+ from flayer.profiles.artifacts import Artifact
15
+ from flayer.profiles.vpn import VpnError
16
+
17
+ QR_CHUNK_BYTES = 850
18
+ QR_MAGIC = 1984
19
+
20
+
21
+ def BuildAmneziaArtifacts(
22
+ client: bytes, host: str, device_id: str, dns_servers: tuple[str, ...],
23
+ *, qr: bool = True,
24
+ ) -> tuple[Artifact, ...]:
25
+ """Wrap one exact Xray policy without server keys or administration credentials."""
26
+
27
+ profile: dict[str, object] = {
28
+ "containers": [{"container": "amnezia-xray", "xray": {
29
+ "last_config": client.decode("utf-8"), "isThirdPartyConfig": True,
30
+ }}],
31
+ "defaultContainer": "amnezia-xray", "hostName": host,
32
+ "description": f"F-Layer VLESS {device_id}",
33
+ "dns1": dns_servers[0], "dns2": dns_servers[1] if len(dns_servers) > 1 else dns_servers[0],
34
+ }
35
+
36
+ return _EncodeProfile(profile, qr=qr)
37
+
38
+
39
+ def _EncodeProfile(profile: dict[str, object], *, qr: bool) -> tuple[Artifact, ...]:
40
+ """Encode one native profile and optionally its scanner frames without network access."""
41
+
42
+ content = json.dumps(profile, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
43
+ # Qt qCompress prefixes the zlib stream with its uncompressed size in network byte order.
44
+ compressed = struct.pack(">I", len(content)) + zlib.compress(content, level=8)
45
+ encoded = base64.urlsafe_b64encode(compressed).rstrip(b"=")
46
+ artifacts = (Artifact("amnezia.vpn", b"vpn://" + encoded + b"\n"),)
47
+
48
+ return artifacts + (_QrArtifacts(compressed) if qr else ())
49
+
50
+
51
+ def _QrArtifacts(compressed: bytes) -> tuple[Artifact, ...]:
52
+ """Render numbered qCompress/QDataStream frames understood by the native scanner."""
53
+
54
+ try:
55
+ segno = importlib.import_module("segno")
56
+
57
+ except ImportError:
58
+ raise VpnError("Install the f-layer[vpn] extra before exporting AmneziaVPN QR codes") from None
59
+
60
+ artifacts: list[Artifact] = []
61
+ chunks = [compressed[index:index + QR_CHUNK_BYTES]
62
+ for index in range(0, len(compressed), QR_CHUNK_BYTES)]
63
+
64
+ if len(chunks) > 255:
65
+ raise VpnError("AmneziaVPN profile exceeds the native QR frame limit")
66
+
67
+ for index, chunk in enumerate(chunks):
68
+ # QDataStream: qint16 magic, quint8 count/index, then a length-prefixed QByteArray.
69
+ frame = struct.pack(">hBBI", QR_MAGIC, len(chunks), index, len(chunk)) + chunk
70
+ payload = base64.urlsafe_b64encode(frame).rstrip(b"=").decode("ascii")
71
+ output = io.BytesIO()
72
+ segno.make_qr(payload, error="L", boost_error=False).save(
73
+ output, kind="svg", scale=5, border=4, light="white",
74
+ )
75
+ artifacts.append(Artifact(f"amnezia-qr-{index + 1:02d}.svg", output.getvalue()))
76
+
77
+ return tuple(artifacts)
78
+
79
+
80
+ def BuildAmneziaQrArtifacts(connection: bytes) -> tuple[Artifact, ...]:
81
+ """Render scanner frames from an existing connection key without changing its profile."""
82
+
83
+ try:
84
+ encoded = connection.strip().removeprefix(b"vpn://")
85
+
86
+ if not connection.startswith(b"vpn://") or not 1 <= len(encoded) <= 1048576:
87
+ raise ValueError
88
+
89
+ compressed = base64.b64decode(encoded + b"=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
90
+
91
+ if base64.urlsafe_b64encode(compressed).rstrip(b"=") != encoded:
92
+ raise ValueError
93
+
94
+ return _QrArtifacts(compressed)
95
+
96
+ except ValueError:
97
+ raise VpnError("Existing AmneziaVPN connection key is invalid") from None
98
+
99
+
100
+ class _AwgConfigParser(configparser.ConfigParser):
101
+ """Preserve case-sensitive AWG protocol keys rather than ConfigParser normalization."""
102
+
103
+ def optionxform(self, optionstr: str) -> str:
104
+ """Honor the parser callback while retaining the external configuration contract."""
105
+
106
+ return optionstr
107
+
108
+
109
+ def BuildAmneziaWgArtifacts(client: bytes, device_id: str, *, qr: bool = False) -> tuple[Artifact, ...]:
110
+ """Wrap existing AWG bytes and structured 3.1 fields without issuing or rotating keys."""
111
+
112
+ from flayer.profiles.amneziawg import AmneziaWgSettings, _Key, _PublicKey
113
+ from flayer.profiles.vpn import VpnEndpoint, VpnRoutes
114
+
115
+ obfuscation = (
116
+ "Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4",
117
+ "I1", "I2", "I3", "I4", "I5", "HeaderProtectionKey", "ContentPaddingAddition",
118
+ "RekeyAfterTime", "RekeyTimeout", "RejectAfterTime", "KeepaliveTimeout",
119
+ "MaxHandshakeAttempts", "RandomTrailers", "DisableCookies",
120
+ )
121
+
122
+ try:
123
+ parser = _AwgConfigParser(interpolation=None, strict=True)
124
+ parser.read_string(client.decode("utf-8"))
125
+
126
+ if set(parser.sections()) != {"Interface", "Peer"} or parser.defaults():
127
+ raise ValueError
128
+
129
+ interface, peer = parser["Interface"], parser["Peer"]
130
+ required = {"PrivateKey", "Address", "MTU", "DNS"}
131
+
132
+ if not required <= set(interface) or set(interface) - required - set(obfuscation):
133
+ raise ValueError
134
+
135
+ if set(peer) != {"PublicKey", "Endpoint", "AllowedIPs", "PersistentKeepalive"}:
136
+ raise ValueError
137
+
138
+ host, port = peer["Endpoint"].rsplit(":", 1)
139
+ endpoint = VpnEndpoint(host, int(port))
140
+ routes = tuple(item.strip() for item in peer["AllowedIPs"].split(","))
141
+ dns_servers = tuple(item.strip() for item in interface["DNS"].split(","))
142
+ VpnRoutes("full" if routes == ("0.0.0.0/0",) else "split", routes, dns_servers, "disabled")
143
+ AmneziaWgSettings(mtu=int(interface["MTU"]))
144
+ address = ipaddress.IPv4Interface(interface["Address"])
145
+
146
+ if address.network.prefixlen != 32 or not address.ip.is_private or len(dns_servers) > 2:
147
+ raise ValueError
148
+
149
+ if not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
150
+ raise ValueError
151
+
152
+ private_key = _Key(interface["PrivateKey"])
153
+ public_key = _PublicKey(private_key)
154
+ server_key = _Key(peer["PublicKey"])
155
+ fields: dict[str, object] = {
156
+ "config": client.decode("utf-8"), "hostName": endpoint.host, "port": endpoint.port,
157
+ "client_ip": str(address.ip),
158
+ "client_priv_key": private_key, "client_pub_key": public_key, "clientId": public_key,
159
+ "server_pub_key": server_key, "allowed_ips": list(routes), "mtu": interface["MTU"],
160
+ "persistent_keep_alive": peer["PersistentKeepalive"],
161
+ }
162
+ fields.update({key: interface[key] for key in obfuscation if key in interface})
163
+ last_config = json.dumps(fields, ensure_ascii=False, separators=(",", ":"))
164
+ profile: dict[str, object] = {
165
+ "containers": [{"container": "amnezia-awg", "awg": {
166
+ "last_config": last_config, "isThirdPartyConfig": True,
167
+ }}],
168
+ "defaultContainer": "amnezia-awg", "hostName": host,
169
+ "description": f"F-Layer AmneziaWG {device_id}",
170
+ "dns1": dns_servers[0], "dns2": dns_servers[1] if len(dns_servers) > 1 else dns_servers[0],
171
+ }
172
+
173
+ except (ValueError, KeyError, configparser.Error, UnicodeError):
174
+ raise VpnError("Existing AmneziaWG device configuration is invalid or unsupported") from None
175
+
176
+ return _EncodeProfile(profile, qr=qr)