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
flayer/vpn_project.py ADDED
@@ -0,0 +1,828 @@
1
+ """Private local VPN projects composed with owned Yandex lifecycle and trusted guest provisioning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import ipaddress
8
+ import json
9
+ import os
10
+ import struct
11
+ import subprocess
12
+ import time
13
+ import tomllib
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ from typing import cast
18
+
19
+ from flayer.core.contracts import (
20
+ ParseIdentity,
21
+ RequireTable,
22
+ ValidateFields,
23
+ ValidateIdentifier,
24
+ ValidateName,
25
+ ValidateSchemaVersion,
26
+ )
27
+ from flayer.core.lifecycle import DeploymentPlan, LifecycleEngine, LifecycleReport
28
+ from flayer.core.state import LoadState
29
+ from flayer.guest import (
30
+ PrivatePath,
31
+ ReadPrivate,
32
+ SshGuestProvisioner,
33
+ ValidateSshPathSyntax,
34
+ WritePrivate,
35
+ )
36
+ from flayer.profiles.amneziawg import (
37
+ AmneziaWgServiceName,
38
+ AmneziaWgSettings,
39
+ DecodeAmneziaWgSecrets,
40
+ EncodeAmneziaWgSecrets,
41
+ GenerateAmneziaWgSecrets,
42
+ PrepareAmneziaWg,
43
+ )
44
+ from flayer.profiles.artifacts import (
45
+ Artifact,
46
+ ArtifactBundle,
47
+ ReadOwnedArtifactFile,
48
+ WriteArtifactBundle,
49
+ )
50
+ from flayer.profiles.vless import (
51
+ DecodeVlessMaterial,
52
+ EncodeVlessMaterial,
53
+ GenerateVlessMaterial,
54
+ PrepareVless,
55
+ VlessRealitySettings,
56
+ VlessServiceName,
57
+ )
58
+ from flayer.profiles.vpn import (
59
+ ParseVpnProfile,
60
+ PreparedVpnTransport,
61
+ VpnEndpoint,
62
+ VpnError,
63
+ VpnProfile,
64
+ )
65
+ from flayer.providers.contracts import (
66
+ ProviderErrorCode,
67
+ ProviderResource,
68
+ ResourceKind,
69
+ ResourceReference,
70
+ )
71
+ from flayer.providers.lifecycle import JsonValue, ResourceSpec
72
+ from flayer.providers.yandex import YandexCloudSettings
73
+ from flayer.providers.yandex_lifecycle import ValidateComputeShape, YandexLifecycleProvider
74
+
75
+ ADMIN_USERNAME = "flayer-admin"
76
+ MAX_SERIAL_BYTES = 1024 * 1024
77
+ PROJECT_TEMPLATE = '''# Edit the folder, image, management address, and Reality target before preparation.
78
+ # Project files contain private keys and must remain outside Git and shared directories.
79
+ schema_version = 1
80
+
81
+ [identity]
82
+ project = "my-vpn"
83
+ stack = "gateway"
84
+ provider = "yandex-cloud"
85
+ scope_id = "replace-with-folder-id"
86
+ owner_id = "my-team"
87
+
88
+ [cloud]
89
+ yc_profile = "default"
90
+ zone_id = "ru-central1-a"
91
+ image_id = "replace-with-ubuntu-2404-amd64-image-id"
92
+ subnet_cidr = "10.42.0.0/24"
93
+ management_cidrs = ["198.51.100.42/32"] # Replace with your current public IPv4 address.
94
+
95
+ # Optional explicit sizing: uncomment the entire block before prepare.
96
+ # Confirm regional availability and image minimum disk size first.
97
+ # [resources]
98
+ # platform_id = "standard-v3"
99
+ # cores = 2
100
+ # core_fraction = 50
101
+ # memory_gib = 2
102
+ # disk_size_gib = 10
103
+ # disk_type = "network-hdd"
104
+
105
+ [routes]
106
+ mode = "full"
107
+ ipv4_cidrs = ["0.0.0.0/0"]
108
+ dns_servers = ["1.1.1.1"]
109
+ ipv6_policy = "disabled" # Disable client IPv6 to prevent traffic bypass.
110
+
111
+ [[devices]]
112
+ device_id = "laptop"
113
+ ipv4_address = "10.66.0.2"
114
+
115
+ [amneziawg]
116
+ port = 51820
117
+ tunnel_cidr = "10.66.0.0/24"
118
+
119
+ [vless]
120
+ port = 443
121
+ # Example only: choose an accessible TLS 1.3 / HTTP/2 site for your server location.
122
+ target_host = "www.microsoft.com"
123
+ server_name = "www.microsoft.com"
124
+ '''
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class CloudPlacement:
129
+ """Public placement and narrowly scoped administrator ingress for one Ubuntu guest."""
130
+
131
+ yc_profile: str
132
+ zone_id: str
133
+ image_id: str
134
+ subnet_cidr: str
135
+ management_cidrs: tuple[str, ...]
136
+
137
+ def __post_init__(self) -> None:
138
+ """Reject placeholders, public subnet ranges, and broadly exposed administration."""
139
+
140
+ for value in (self.yc_profile, self.zone_id, self.image_id):
141
+ ValidateIdentifier(value, "cloud setting")
142
+
143
+ if value.startswith("replace-with-"):
144
+ raise VpnError("Replace the example cloud identifiers in project.toml before preparation")
145
+
146
+ try:
147
+ network = ipaddress.IPv4Network(self.subnet_cidr, strict=True)
148
+ private = tuple(ipaddress.IPv4Network(item) for item in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"))
149
+
150
+ if str(network) != self.subnet_cidr or not 16 <= network.prefixlen <= 28 or not any(network.subnet_of(item) for item in private):
151
+ raise ValueError
152
+
153
+ if not isinstance(self.management_cidrs, tuple) or not 1 <= len(self.management_cidrs) <= 16:
154
+ raise ValueError
155
+
156
+ sources = tuple(ipaddress.IPv4Network(item, strict=True) for item in self.management_cidrs)
157
+
158
+ if any(str(network) != item or network.prefixlen < 24 for network, item in zip(sources, self.management_cidrs, strict=True)):
159
+ raise ValueError
160
+
161
+ if any(left.overlaps(right) for index, left in enumerate(sources) for right in sources[index + 1:]):
162
+ raise ValueError
163
+
164
+ except (TypeError, ValueError):
165
+ raise VpnError("Cloud subnet must be private /16../28 and management sources unique IPv4 /24../32") from None
166
+
167
+
168
+ def _PublicKey(content: bytes) -> str:
169
+ """Read only a complete Ed25519 public key without exposing invalid file content."""
170
+
171
+ try:
172
+ parts = content.decode("ascii").strip().split()
173
+ prefix = struct.pack(">I", 11) + b"ssh-ed25519" + struct.pack(">I", 32)
174
+
175
+ if len(parts) not in {2, 3} or parts[0] != "ssh-ed25519":
176
+ raise ValueError
177
+
178
+ blob = base64.b64decode(parts[1], validate=True)
179
+
180
+ if not blob.startswith(prefix) or len(blob) != len(prefix) + 32 or base64.b64encode(blob).decode("ascii") != parts[1]:
181
+ raise ValueError
182
+
183
+ return " ".join(parts[:2])
184
+
185
+ except (ValueError, UnicodeError):
186
+ raise VpnError("Project administration and guest trust require valid Ed25519 public keys") from None
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class VpnResources:
191
+ """Explicit initial server sizing; absence preserves the legacy project contract."""
192
+
193
+ platform_id: str
194
+ cores: int
195
+ core_fraction: int
196
+ memory_gib: int
197
+ disk_size_gib: int
198
+ disk_type: str
199
+
200
+ def __post_init__(self) -> None:
201
+ """Reject unsupported resource requests before key generation or cloud access."""
202
+
203
+ try:
204
+ ValidateComputeShape({
205
+ "platform_id": self.platform_id, "cores": self.cores,
206
+ "core_fraction": self.core_fraction, "memory_gib": self.memory_gib,
207
+ })
208
+
209
+ if type(self.disk_size_gib) is not int or not 10 <= self.disk_size_gib <= 1024:
210
+ raise ValueError("disk_size_gib must be an integer between 10 and 1024")
211
+
212
+ if self.disk_type not in ("network-hdd", "network-ssd"):
213
+ raise ValueError("disk_type must be network-hdd or network-ssd")
214
+
215
+ except ValueError as error:
216
+ raise VpnError(str(error)) from None
217
+
218
+
219
+ @dataclass(frozen=True, slots=True)
220
+ class VpnProject:
221
+ """Validated public project intent with explicit private local storage references."""
222
+
223
+ root: Path = field(repr=False)
224
+ vpn: VpnProfile
225
+ cloud: CloudPlacement
226
+ amneziawg: AmneziaWgSettings
227
+ vless: VlessRealitySettings
228
+ ssh_public_key: str = field(repr=False)
229
+ resources: VpnResources | None = None
230
+
231
+ def Fingerprint(self) -> str:
232
+ """Bind prepared material to all public project settings without persisting secrets."""
233
+
234
+ payload: dict[str, object] = {
235
+ "vpn": asdict(self.vpn), "cloud": asdict(self.cloud),
236
+ "amneziawg": asdict(self.amneziawg), "vless": asdict(self.vless),
237
+ "ssh_public_key": self.ssh_public_key,
238
+ }
239
+
240
+ if self.resources is not None:
241
+ payload["resources"] = asdict(self.resources)
242
+
243
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
244
+
245
+ @property
246
+ def Artifacts(self) -> Path:
247
+ """Locate the existing private owned-artifact store within this project."""
248
+
249
+ return self.root / "artifacts"
250
+
251
+
252
+ @dataclass(frozen=True, slots=True)
253
+ class VpnProjectReport:
254
+ """Sanitized lifecycle and guest observations without transport material or provider output."""
255
+
256
+ action: str
257
+ status: str
258
+ cloud_status: str = "not-requested"
259
+ guest_status: str = "not-requested"
260
+ client_exports: tuple[str, ...] = ()
261
+ recovery_required: bool = False
262
+ details: str = ""
263
+ connectivity_status: str = "not-verified"
264
+
265
+ def ExitCode(self) -> int:
266
+ """Treat incomplete cloud or guest operations as unsuccessful without claiming connectivity."""
267
+
268
+ return 0 if self.status == "complete" else 1
269
+
270
+
271
+ def InitializeVpnProject(directory: str | Path) -> Path:
272
+ """Create one exclusive private project and an administration key without cloud access."""
273
+
274
+ path = Path(directory).absolute()
275
+ ValidateSshPathSyntax(path)
276
+
277
+ if os.name != "posix" or ".." in path.parts or any(item.is_symlink() for item in (path, *path.parents)):
278
+ raise VpnError("Project initialization requires a POSIX path without symlinks or traversal")
279
+
280
+ try:
281
+ path.mkdir(mode=0o700)
282
+
283
+ except OSError:
284
+ raise VpnError("Project directory must be new and its parent must already exist") from None
285
+
286
+ WritePrivate(path / "project.toml", PROJECT_TEMPLATE.encode("utf-8"))
287
+ WritePrivate(path / ".gitignore", b"*\n")
288
+
289
+ try:
290
+ result = subprocess.run(
291
+ ("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "flayer-admin", "-f", str(path / "admin-key")),
292
+ stdin=subprocess.DEVNULL, capture_output=True, check=False, timeout=30,
293
+ )
294
+
295
+ if result.returncode != 0:
296
+ raise VpnError("Unable to generate the project administration key; inspect the private project directory")
297
+
298
+ (path / "admin-key.pub").chmod(0o600)
299
+ PrivatePath(path / "admin-key")
300
+ _PublicKey(ReadPrivate(path / "admin-key.pub"))
301
+ (path / "artifacts").mkdir(mode=0o700)
302
+
303
+ except (OSError, subprocess.TimeoutExpired):
304
+ raise VpnError("Project initialization requires the OpenSSH ssh-keygen command") from None
305
+
306
+ return path
307
+
308
+
309
+ def _Table(data: Mapping[str, object], key: str, fields: frozenset[str]) -> Mapping[str, object]:
310
+ """Validate exact public nested fields before interpreting cloud or transport settings."""
311
+
312
+ table = RequireTable(data[key], key)
313
+ ValidateFields(table, fields, frozenset(), key)
314
+
315
+ return table
316
+
317
+
318
+ def LoadVpnProject(directory: str | Path) -> VpnProject:
319
+ """Read explicit editable public TOML and administration public key from private storage."""
320
+
321
+ ValidateSshPathSyntax(Path(directory).absolute())
322
+ root = PrivatePath(Path(directory), directory=True)
323
+ PrivatePath(root / "artifacts", directory=True)
324
+
325
+ try:
326
+ data = tomllib.loads(ReadPrivate(root / "project.toml").decode("utf-8"))
327
+ ValidateFields(data, frozenset({
328
+ "schema_version", "identity", "cloud", "routes", "devices", "amneziawg", "vless",
329
+ }), frozenset({"resources"}), "VPN project")
330
+ ValidateSchemaVersion(data["schema_version"])
331
+ cloud = _Table(data, "cloud", frozenset({"yc_profile", "zone_id", "image_id", "subnet_cidr", "management_cidrs"}))
332
+ awg = _Table(data, "amneziawg", frozenset({"port", "tunnel_cidr"}))
333
+ vless = _Table(data, "vless", frozenset({"port", "target_host", "server_name"}))
334
+ identity = ParseIdentity(data["identity"])
335
+
336
+ if identity.provider != "yandex-cloud" or identity.scope_id.startswith("replace-with-"):
337
+ raise VpnError("Project requires an explicit Yandex Cloud folder ID")
338
+
339
+ if not isinstance(cloud["management_cidrs"], list) or any(
340
+ not isinstance(cloud[key], str) for key in ("yc_profile", "zone_id", "image_id", "subnet_cidr")
341
+ ):
342
+ raise VpnError("Cloud placement requires string settings and a management CIDR array")
343
+
344
+ if not isinstance(awg["tunnel_cidr"], str) or not isinstance(vless["target_host"], str) or not isinstance(vless["server_name"], str):
345
+ raise VpnError("Transport network and TLS target settings must be strings")
346
+
347
+ vpn = ParseVpnProfile({
348
+ "schema_version": 1, "identity": data["identity"], "routes": data["routes"],
349
+ "devices": data["devices"], "listeners": [
350
+ {"transport": "amneziawg", "protocol": "udp", "port": awg["port"], "source_cidrs": ["0.0.0.0/0"]},
351
+ {"transport": "vless-reality", "protocol": "tcp", "port": vless["port"], "source_cidrs": ["0.0.0.0/0"]},
352
+ ],
353
+ })
354
+
355
+ if vless["port"] == 22:
356
+ raise VpnError("VLESS listener must not collide with the administration SSH port")
357
+
358
+ for suffix in ("credentials", "bootstrap", "trust"):
359
+ ValidateName(identity.stack + "-" + suffix, "project artifact name")
360
+
361
+ placement = CloudPlacement(
362
+ cast(str, cloud["yc_profile"]), cast(str, cloud["zone_id"]), cast(str, cloud["image_id"]),
363
+ cast(str, cloud["subnet_cidr"]), tuple(cloud["management_cidrs"]),
364
+ )
365
+ resources = None
366
+
367
+ if "resources" in data:
368
+ sizing = _Table(data, "resources", frozenset({
369
+ "platform_id", "cores", "core_fraction", "memory_gib", "disk_size_gib", "disk_type",
370
+ }))
371
+ resources = VpnResources(
372
+ cast(str, sizing["platform_id"]), cast(int, sizing["cores"]),
373
+ cast(int, sizing["core_fraction"]), cast(int, sizing["memory_gib"]),
374
+ cast(int, sizing["disk_size_gib"]), cast(str, sizing["disk_type"]),
375
+ )
376
+
377
+ if placement.zone_id.startswith("kz1-") and resources.platform_id != "standard-v3":
378
+ raise VpnError("Kazakhstan VPN deployments require platform_id standard-v3")
379
+
380
+ settings = AmneziaWgSettings(awg["tunnel_cidr"])
381
+
382
+ if ipaddress.IPv4Network(settings.tunnel_cidr).overlaps(ipaddress.IPv4Network(placement.subnet_cidr)):
383
+ raise VpnError("VPN tunnel and cloud subnet must not overlap")
384
+
385
+ return VpnProject(
386
+ root, vpn, placement, settings, VlessRealitySettings(vless["target_host"], vless["server_name"]),
387
+ _PublicKey(ReadPrivate(root / "admin-key.pub")), resources,
388
+ )
389
+
390
+ except (ValueError, RecursionError) as error:
391
+ if isinstance(error, VpnError):
392
+ raise
393
+
394
+ raise VpnError("Project configuration is invalid; check its documented fields and types") from None
395
+
396
+
397
+ def _Publish(project: VpnProject, bundle: ArtifactBundle) -> Path:
398
+ """Reuse verified bundles, allowing only legacy VLESS installer newline equivalence."""
399
+
400
+ directory = project.Artifacts / f"{bundle.kind}-{bundle.name}"
401
+
402
+ if directory.exists() or directory.is_symlink():
403
+ for artifact in bundle.files:
404
+ actual = ReadOwnedArtifactFile(directory / artifact.name, bundle.identity, kind=bundle.kind, name=bundle.name)
405
+
406
+ if (
407
+ bundle.kind == "server"
408
+ and bundle.name == project.vpn.identity.stack + "-vless-reality"
409
+ and artifact.name == "install.py"
410
+ ):
411
+ # Verify the original manifest first; preserve its bytes instead of migrating secrets.
412
+ actual = actual.replace(b"\r\n", b"\n")
413
+
414
+ if actual != artifact.content:
415
+ raise VpnError("Existing project artifacts differ. Keep the old project for cloud cleanup, then initialize a new private project")
416
+
417
+ return directory
418
+
419
+ return WriteArtifactBundle(project.Artifacts, bundle)
420
+
421
+
422
+ def _MaterialBundle(project: VpnProject) -> ArtifactBundle:
423
+ """Issue credentials once and retain them only in a separate controller-owned private bundle."""
424
+
425
+ name = project.vpn.identity.stack + "-credentials"
426
+ directory = project.Artifacts / f"server-{name}"
427
+ proof = (project.Fingerprint() + "\n").encode("ascii")
428
+
429
+ if directory.exists() or directory.is_symlink():
430
+ existing_proof = ReadOwnedArtifactFile(directory / "project.sha256", project.vpn.identity, kind="server", name=name)
431
+
432
+ if existing_proof != proof:
433
+ raise VpnError("Project settings changed after preparation. Restore the old settings for destroy, then initialize a new private project")
434
+
435
+ files = tuple(Artifact(filename, ReadOwnedArtifactFile(
436
+ directory / filename, project.vpn.identity, kind="server", name=name,
437
+ )) for filename in ("amneziawg.json", "vless.json"))
438
+ DecodeAmneziaWgSecrets(files[0].content)
439
+ DecodeVlessMaterial(files[1].content)
440
+
441
+ return ArtifactBundle(project.vpn.identity, "server", name, (*files, Artifact("project.sha256", proof, False)))
442
+
443
+ return ArtifactBundle(project.vpn.identity, "server", name, (
444
+ Artifact("amneziawg.json", EncodeAmneziaWgSecrets(GenerateAmneziaWgSecrets(project.vpn))),
445
+ Artifact("vless.json", EncodeVlessMaterial(GenerateVlessMaterial(project.vpn))),
446
+ Artifact("project.sha256", proof, False),
447
+ ))
448
+
449
+
450
+ def _Transports(project: VpnProject, material: ArtifactBundle, endpoint: str) -> tuple[PreparedVpnTransport, ...]:
451
+ """Render both protocols from retained credentials and one observed server IPv4 endpoint."""
452
+
453
+ content = {item.name: item.content for item in material.files}
454
+ ports = {item.transport: item.port for item in project.vpn.listeners}
455
+
456
+ return (
457
+ PrepareAmneziaWg(project.vpn, VpnEndpoint(endpoint, ports["amneziawg"]), project.amneziawg, DecodeAmneziaWgSecrets(content["amneziawg.json"])),
458
+ PrepareVless(project.vpn, VpnEndpoint(endpoint, ports["vless-reality"]), project.vless, DecodeVlessMaterial(content["vless.json"])),
459
+ )
460
+
461
+
462
+ def _Bootstrap(project: VpnProject) -> ArtifactBundle:
463
+ """Create a nonsecret fresh Ubuntu bootstrap with separate VPN forwarding ownership."""
464
+
465
+ firewall = [
466
+ "#!/usr/sbin/nft -f", "flush ruleset", "table inet flayer_bootstrap {", " chain input {",
467
+ " type filter hook input priority 0; policy drop;", " ct state invalid drop",
468
+ " ct state established,related accept", ' iifname "lo" accept',
469
+ " udp sport 67 udp dport 68 accept", " ip protocol icmp limit rate 10/second accept",
470
+ ]
471
+
472
+ for cidr in project.cloud.management_cidrs:
473
+ firewall.append(f" ip saddr {cidr} tcp dport 22 accept")
474
+
475
+ for listener in project.vpn.listeners:
476
+ for cidr in listener.source_cidrs:
477
+ firewall.append(f" ip saddr {cidr} {listener.protocol} dport {listener.port} accept")
478
+
479
+ firewall.extend([" }", "}", ""])
480
+ ssh_config = "\n".join((
481
+ "Port 22", "UsePAM yes", "PermitRootLogin no", "PasswordAuthentication no", "KbdInteractiveAuthentication no",
482
+ "PubkeyAuthentication yes", "AuthenticationMethods publickey", "AllowUsers " + ADMIN_USERNAME,
483
+ "AuthorizedKeysFile /etc/ssh/flayer-admin-keys", "AuthorizedKeysCommand none", "TrustedUserCAKeys none",
484
+ "X11Forwarding no", "AllowAgentForwarding no", "AllowTcpForwarding no", "PermitTunnel no",
485
+ "HostKey /etc/ssh/ssh_host_ed25519_key", "",
486
+ ))
487
+ bootstrap = "\n".join((
488
+ "#!/bin/sh", "set -eu", "install -d -m 0755 /run/sshd", "sshd -t",
489
+ "nft --check --file /etc/nftables.conf", "sysctl --system",
490
+ "systemctl enable --now nftables", "systemctl disable --now ssh.socket",
491
+ "systemctl daemon-reload", "systemctl enable --now ssh.service", "systemctl restart ssh.service",
492
+ "install -d -m 0700 /var/lib/f-layer", "touch /var/lib/f-layer/bootstrap-ready",
493
+ "printf 'FLAYER_HOSTKEY " + project.Fingerprint() + " '",
494
+ "cut -d ' ' -f 1,2 /etc/ssh/ssh_host_ed25519_key.pub", "",
495
+ ))
496
+ files = (
497
+ ("/etc/ssh/sshd_config", "0644", ssh_config),
498
+ ("/etc/ssh/flayer-admin-keys", "0644", project.ssh_public_key + "\n"),
499
+ ("/etc/nftables.conf", "0644", "\n".join(firewall)),
500
+ ("/etc/sysctl.d/90-flayer-vpn.conf", "0644", "net.ipv4.ip_forward=0\nnet.ipv6.conf.all.disable_ipv6=1\nnet.ipv6.conf.default.disable_ipv6=1\n"),
501
+ ("/usr/local/sbin/flayer-bootstrap", "0700", bootstrap),
502
+ )
503
+ document = {
504
+ "ssh_pwauth": False, "disable_root": True,
505
+ "users": [{"name": ADMIN_USERNAME, "groups": ["sudo"], "shell": "/bin/bash", "lock_passwd": True,
506
+ "sudo": "ALL=(ALL) NOPASSWD:ALL", "ssh_authorized_keys": [project.ssh_public_key]}],
507
+ "package_update": True, "package_upgrade": False,
508
+ "packages": ["nftables", "python3", "ca-certificates"],
509
+ "write_files": [{"path": path, "owner": "root:root", "permissions": permissions, "content": content}
510
+ for path, permissions, content in files],
511
+ "runcmd": [["/bin/sh", "-c", "/usr/local/sbin/flayer-bootstrap > /dev/ttyS0 2>&1"]],
512
+ }
513
+ content = ("#cloud-config\n" + json.dumps(document, indent=2, sort_keys=True) + "\n").encode("utf-8")
514
+
515
+ return ArtifactBundle(project.vpn.identity, "server", project.vpn.identity.stack + "-bootstrap", (
516
+ Artifact("cloud-init.json", content, False),
517
+ ))
518
+
519
+
520
+ def _VerifyAdministrationKey(project: VpnProject) -> None:
521
+ """Check the private administration key matches its public bootstrap value before cloud creation."""
522
+
523
+ path = PrivatePath(project.root / "admin-key")
524
+
525
+ try:
526
+ result = subprocess.run(
527
+ ("ssh-keygen", "-y", "-P", "", "-f", str(path)),
528
+ stdin=subprocess.DEVNULL, capture_output=True, check=False, timeout=10,
529
+ )
530
+
531
+ if result.returncode != 0 or _PublicKey(result.stdout) != project.ssh_public_key:
532
+ raise VpnError("Administration key does not match admin-key.pub; restore the original project key pair")
533
+
534
+ except (OSError, subprocess.TimeoutExpired):
535
+ raise VpnError("Unable to verify the project administration key with ssh-keygen") from None
536
+
537
+
538
+ def PrepareVpnProject(project: VpnProject) -> VpnProjectReport:
539
+ """Prepare private materials and verify both renderers locally without exporting false endpoints."""
540
+
541
+ ValidateSshPathSyntax(project.root)
542
+ _VerifyAdministrationKey(project)
543
+ material = _MaterialBundle(project)
544
+ transports = _Transports(project, material, "192.0.2.1")
545
+ bootstrap = _Bootstrap(project)
546
+ _Publish(project, material)
547
+
548
+ for transport in transports:
549
+ _Publish(project, transport.server_bundle)
550
+
551
+ _Publish(project, bootstrap)
552
+
553
+ return VpnProjectReport("prepare", "complete", details="Local server artifacts prepared; client exports follow actual address allocation")
554
+
555
+
556
+ def _Freeze(value: object) -> JsonValue:
557
+ """Build immutable provider parameters from local compiler-owned dictionaries and arrays."""
558
+
559
+ if isinstance(value, dict):
560
+ return tuple((str(key), _Freeze(item)) for key, item in value.items())
561
+
562
+ if isinstance(value, (list, tuple)):
563
+ return tuple(_Freeze(item) for item in value)
564
+
565
+ if value is None or type(value) in {str, int, bool}:
566
+ return cast(JsonValue, value)
567
+
568
+ raise VpnError("Compiled cloud parameters contain an unsupported value")
569
+
570
+
571
+ def CompileVpnProject(project: VpnProject) -> DeploymentPlan:
572
+ """Compile six owned resources while binding only the verified nonsecret bootstrap file."""
573
+
574
+ bundle = _Bootstrap(project)
575
+ directory = project.Artifacts / f"server-{bundle.name}"
576
+ path = directory / "cloud-init.json"
577
+ actual = ReadOwnedArtifactFile(path, project.vpn.identity, kind="server", name=bundle.name)
578
+
579
+ if actual != bundle.files[0].content:
580
+ raise VpnError("Prepared bootstrap does not match the current public project settings")
581
+
582
+ resources: list[ResourceSpec] = []
583
+
584
+ def Add(logical_id: str, kind: ResourceKind, dependencies: tuple[str, ...], options: dict[str, object]) -> None:
585
+ """Append one exact typed resource to the owned dependency graph."""
586
+
587
+ resources.append(ResourceSpec(logical_id, kind, ValidateName(project.vpn.identity.stack + "-" + logical_id, "resource name"),
588
+ tuple((key, _Freeze(value)) for key, value in options.items()), dependencies))
589
+
590
+ Add("network", ResourceKind.NETWORK, (), {})
591
+ Add("subnet", ResourceKind.SUBNET, ("network",), {
592
+ "zone_id": project.cloud.zone_id, "ipv4_cidr": project.cloud.subnet_cidr, "network_dependency": "network",
593
+ })
594
+ rules: list[object] = [{"direction": "ingress", "protocol": "tcp", "from_port": 22, "to_port": 22, "cidr": cidr}
595
+ for cidr in project.cloud.management_cidrs]
596
+ rules.extend({"direction": "ingress", "protocol": listener.protocol, "from_port": listener.port,
597
+ "to_port": listener.port, "cidr": cidr} for listener in project.vpn.listeners for cidr in listener.source_cidrs)
598
+ rules.append({"direction": "egress", "protocol": "any", "cidr": "0.0.0.0/0"})
599
+ Add("firewall", ResourceKind.SECURITY_GROUP, ("network",), {"network_dependency": "network", "rules": rules})
600
+ Add("address", ResourceKind.ADDRESS, (), {"zone_id": project.cloud.zone_id})
601
+ disk: dict[str, object] = {"zone_id": project.cloud.zone_id, "image_id": project.cloud.image_id, "size_gib": 20}
602
+ instance: dict[str, object] = {
603
+ "zone_id": project.cloud.zone_id, "cores": 2, "memory_gib": 2, "boot_disk_dependency": "boot-disk",
604
+ "subnet_dependency": "subnet", "security_group_dependency": "firewall", "address_dependency": "address",
605
+ "ssh_public_key": project.ssh_public_key, "ssh_username": ADMIN_USERNAME, "user_data_file": str(path),
606
+ "user_data_sha256": hashlib.sha256(actual).hexdigest(),
607
+ }
608
+
609
+ if project.resources is not None:
610
+ sizing = project.resources
611
+ disk.update(size_gib=sizing.disk_size_gib, type=sizing.disk_type)
612
+ instance.update(cores=sizing.cores, memory_gib=sizing.memory_gib,
613
+ platform_id=sizing.platform_id, core_fraction=sizing.core_fraction)
614
+
615
+ Add("boot-disk", ResourceKind.DISK, (), disk)
616
+ Add("instance", ResourceKind.INSTANCE, ("subnet", "firewall", "address", "boot-disk"), instance)
617
+
618
+ return DeploymentPlan(project.vpn.identity, tuple(resources))
619
+
620
+
621
+ class VpnYandexProvider(YandexLifecycleProvider):
622
+ """Add one authenticated read-only public host-key channel to the existing lifecycle adapter."""
623
+
624
+ def ReadHostKey(self, instance: ProviderResource, fingerprint: str) -> str | None:
625
+ """Extract one exact bootstrap marker without logging unrelated serial-console contents."""
626
+
627
+ observed = self.GetResource(instance.reference)
628
+
629
+ if observed.reference != instance.reference or observed.labels != instance.labels:
630
+ raise VpnError("Instance ownership changed before authenticated host-key retrieval")
631
+
632
+ payload = self._ReadJson(("compute", "instance", "get-serial-port-output", "--id", instance.reference.resource_id, "--port", "1"), "guest-host-key")
633
+
634
+ if not isinstance(payload, dict) or not isinstance(payload.get("contents"), str):
635
+ raise VpnError("Authenticated serial-console response is unavailable or malformed")
636
+
637
+ contents = payload["contents"]
638
+
639
+ try:
640
+ if len(contents.encode("utf-8")) > MAX_SERIAL_BYTES:
641
+ raise VpnError("Authenticated serial-console response exceeds its bounded size")
642
+
643
+ marker = "FLAYER_HOSTKEY " + fingerprint + " "
644
+ keys = {_PublicKey(line[len(marker):].encode("ascii")) for line in contents.splitlines() if line.startswith(marker)}
645
+
646
+ except UnicodeError:
647
+ raise VpnError("Authenticated serial-console host-key marker contains invalid encoding") from None
648
+
649
+ if len(keys) > 1:
650
+ raise VpnError("Authenticated serial console contains conflicting host-key pins")
651
+
652
+ return next(iter(keys)) if keys else None
653
+
654
+
655
+ def _OwnedEndpoints(project: VpnProject, provider: VpnYandexProvider) -> tuple[ProviderResource, str]:
656
+ """Resolve the exact persisted instance and address, then independently verify every owner field."""
657
+
658
+ state = LoadState(project.root / "state.json", project.vpn.identity)
659
+
660
+ if state is None:
661
+ raise VpnError("Cloud state is unavailable; deploy the owned project first")
662
+
663
+ observed = {}
664
+
665
+ for logical_id, kind in (("instance", ResourceKind.INSTANCE), ("address", ResourceKind.ADDRESS)):
666
+ locator = next((item for item in state.resources if item.logical_id == logical_id), None)
667
+
668
+ if locator is None or locator.kind != kind.value:
669
+ raise VpnError("Required cloud instance or reserved address is not recorded")
670
+
671
+ reference = ResourceReference(project.vpn.identity.provider, project.vpn.identity.scope_id, kind, locator.resource_id)
672
+ resource = provider.GetResource(reference)
673
+ labels = tuple(sorted({**project.vpn.identity.OwnershipLabels(), "flayer-resource": logical_id}.items()))
674
+
675
+ if resource.reference != reference or not resource.HasLabels(labels):
676
+ raise VpnError("Observed VPN endpoint does not match the exact persisted ownership")
677
+
678
+ observed[logical_id] = resource
679
+
680
+ address = observed["address"].public_addresses
681
+
682
+ if len(address) != 1 or observed["instance"].public_addresses != address:
683
+ raise VpnError("Guest public IPv4 must match the exact owned reserved address")
684
+
685
+ VpnEndpoint(address[0], 22)
686
+
687
+ return observed["instance"], address[0]
688
+
689
+
690
+ def _HostTrust(
691
+ project: VpnProject, provider: VpnYandexProvider, instance: ProviderResource, endpoint: str,
692
+ *, wait: bool, sleep: Callable[[float], None] = time.sleep,
693
+ ) -> Path:
694
+ """Pin an IAM-authenticated public guest key and refuse changes to any existing trusted pin."""
695
+
696
+ key = None
697
+
698
+ for attempt in range(25 if wait else 1):
699
+ key = provider.ReadHostKey(instance, project.Fingerprint())
700
+
701
+ if key is not None:
702
+ break
703
+
704
+ if wait and attempt < 24:
705
+ sleep(5)
706
+
707
+ if key is None:
708
+ raise VpnError("Guest bootstrap host key is not available yet; rerun deploy after cloud-init finishes")
709
+
710
+ content = f"{endpoint} {key}\n".encode("ascii")
711
+ bundle = ArtifactBundle(project.vpn.identity, "server", project.vpn.identity.stack + "-trust", (
712
+ Artifact("known-hosts.txt", content, False),
713
+ ))
714
+ directory = _Publish(project, bundle)
715
+
716
+ return directory / "known-hosts.txt"
717
+
718
+
719
+
720
+ def _Incomplete(action: str, report: LifecycleReport) -> VpnProjectReport:
721
+ """Preserve truthful cloud progress and recovery requirements after an interrupted operation."""
722
+
723
+ return VpnProjectReport(action, "incomplete", report.status, recovery_required=report.recovery_required,
724
+ details="Cloud operation did not finish; retained state is required for recovery")
725
+
726
+
727
+ def _CheckCloudAccess(provider: VpnYandexProvider) -> None:
728
+ """Stop before lifecycle operations when the selected profile cannot read its folder."""
729
+
730
+ status = provider.CheckAuthentication()
731
+
732
+ if status.available and status.authenticated is True and status.error_code is None:
733
+ return
734
+
735
+ code = status.error_code or ProviderErrorCode.COMMAND_FAILED
736
+ guidance = {
737
+ ProviderErrorCode.AUTHENTICATION: "Reauthenticate the project.toml [cloud].yc_profile using the official local sign-in flow; keep credentials out of chat.",
738
+ ProviderErrorCode.PERMISSION_DENIED: "Verify the configured profile and its read permission for identity.scope_id; do not change IAM automatically.",
739
+ ProviderErrorCode.TIMEOUT: "Check connectivity and the configured profile with a read-only folder query; a timeout alone does not prove expired credentials or an API outage.",
740
+ ProviderErrorCode.UNAVAILABLE: "Install or restore access to the Yandex Cloud CLI in this controller environment.",
741
+ }.get(code, "Verify the configured profile, folder and CLI access with a read-only query before retrying.")
742
+
743
+ raise VpnError(f"Cloud access preflight failed ({code.value}). {guidance} No cloud lifecycle operation started; existing state and recovery requirements are unchanged.")
744
+
745
+
746
+ def RunVpnProject(
747
+ project: VpnProject, action: str, *, allow_mutation: bool = False, scope_confirm: str = "",
748
+ rollback: bool = False, provider: VpnYandexProvider | None = None,
749
+ guest_factory: Callable[..., SshGuestProvisioner] = SshGuestProvisioner,
750
+ ) -> VpnProjectReport:
751
+ """Compose explicit cloud consent, durable resources, authenticated guest install, and client export."""
752
+
753
+ ValidateSshPathSyntax(project.root)
754
+
755
+ if action not in {"prepare", "deploy", "status", "destroy", "recover"}:
756
+ raise VpnError("VPN project action is unsupported")
757
+
758
+ if action == "prepare":
759
+ return PrepareVpnProject(project)
760
+
761
+ if action in {"deploy", "destroy", "recover"} and (not allow_mutation or scope_confirm != project.vpn.identity.scope_id):
762
+ raise VpnError("Cloud mutation requires --allow-mutation and exact --scope-confirm folder ID")
763
+
764
+ if action == "deploy":
765
+ state = LoadState(project.root / "state.json", project.vpn.identity)
766
+ old_trust = project.Artifacts / f"server-{project.vpn.identity.stack}-trust"
767
+ old_exports = tuple(project.Artifacts / f"device-{device.device_id}-{listener.transport}"
768
+ for device in project.vpn.devices for listener in project.vpn.listeners)
769
+
770
+ if (state is None or not state.resources) and any(path.exists() or path.is_symlink() for path in (old_trust, *old_exports)):
771
+ raise VpnError("Previous server trust or client exports remain after cloud cleanup. Initialize a new private project before another deployment")
772
+
773
+ PrepareVpnProject(project)
774
+
775
+ plan = CompileVpnProject(project)
776
+ provider = provider or VpnYandexProvider(YandexCloudSettings(project.vpn.identity.scope_id, project.cloud.yc_profile))
777
+ engine = LifecycleEngine(plan, provider, project.root / "state.json")
778
+ _CheckCloudAccess(provider)
779
+
780
+ if action == "destroy":
781
+ report = engine.Destroy()
782
+
783
+ if report.status != "complete":
784
+ return _Incomplete(action, report)
785
+
786
+ return VpnProjectReport(action, "complete", "complete", "destroyed", details="Cloud resources removed; private local artifacts retained")
787
+
788
+ if action == "recover":
789
+ report = engine.Recover(rollback=rollback)
790
+
791
+ if report.status != "complete":
792
+ return _Incomplete(action, report)
793
+
794
+ return VpnProjectReport(action, "complete", "complete", details="Cloud recovery completed; run deploy to provision and check both VPN services")
795
+
796
+ report = engine.Create() if action == "deploy" else engine.Status()
797
+
798
+ if report.status != "complete":
799
+ return _Incomplete(action, report)
800
+
801
+ instance, endpoint = _OwnedEndpoints(project, provider)
802
+ trust = _HostTrust(project, provider, instance, endpoint, wait=action == "deploy")
803
+ material = _MaterialBundle(project)
804
+ transports = _Transports(project, material, endpoint)
805
+ guest = guest_factory(VpnEndpoint(endpoint, 22), ADMIN_USERNAME, project.root / "admin-key", trust)
806
+
807
+ if action == "deploy":
808
+ for transport in transports:
809
+ _Publish(project, transport.server_bundle)
810
+
811
+ guest.Install(tuple((item.capabilities.transport, item.server_bundle) for item in transports))
812
+
813
+ services = (AmneziaWgServiceName(project.vpn) + ".service", VlessServiceName(project.vpn))
814
+ observations = guest.ServiceStatus(services)
815
+
816
+ if not all(active for _, active in observations):
817
+ return VpnProjectReport(action, "incomplete", "complete", "inactive", details="Cloud resources exist but a VPN service is not active")
818
+
819
+ exports = []
820
+
821
+ if action == "deploy":
822
+ for transport in transports:
823
+ for bundle in transport.device_bundles:
824
+ directory = _Publish(project, bundle)
825
+ exports.append(str(directory.relative_to(project.root)))
826
+
827
+ return VpnProjectReport(action, "complete", "complete", "services-active", tuple(exports),
828
+ details="Guest services are active; authenticated client traffic, DNS and Internet access are not verified")