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
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""Compile an explicit IPv4 gateway with restricted SSH application forwarding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import ipaddress
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import struct
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path, PureWindowsPath
|
|
16
|
+
|
|
17
|
+
from flayer.core.contracts import (
|
|
18
|
+
ContractError,
|
|
19
|
+
ParseIdentity,
|
|
20
|
+
RequireTable,
|
|
21
|
+
StackIdentity,
|
|
22
|
+
ValidateFields,
|
|
23
|
+
ValidateIdentifier,
|
|
24
|
+
ValidateName,
|
|
25
|
+
ValidateSchemaVersion,
|
|
26
|
+
)
|
|
27
|
+
from flayer.profiles.artifacts import Artifact, ArtifactBundle, ReadOwnedArtifactFile
|
|
28
|
+
|
|
29
|
+
PROFILE_SCHEMA_VERSION = 1
|
|
30
|
+
PROFILE_NAME = "secure-gateway"
|
|
31
|
+
MAX_PROFILE_BYTES = 65536
|
|
32
|
+
_USERNAME_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,31}\Z")
|
|
33
|
+
_HOST_PATTERN = re.compile(r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?\Z")
|
|
34
|
+
_IDENTITY_FIELDS = frozenset({"project", "stack", "provider", "scope_id", "owner_id"})
|
|
35
|
+
_GATEWAY_FIELDS = frozenset({
|
|
36
|
+
"zone_id", "image_id", "subnet_cidr", "ssh_public_key", "management_cidrs",
|
|
37
|
+
})
|
|
38
|
+
_OPTIONAL_GATEWAY_FIELDS = frozenset({
|
|
39
|
+
"ssh_username", "ssh_port", "cores", "memory_gib", "boot_disk_gib",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GatewayError(ContractError):
|
|
44
|
+
"""Profile intent or a prepared initialization artifact violates its contract."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _Integer(value: object, minimum: int, maximum: int, label: str) -> int:
|
|
48
|
+
"""Reject coercion and enforce a finite profile sizing or port limit."""
|
|
49
|
+
|
|
50
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
51
|
+
raise GatewayError(f"{label} must be an integer within {minimum}..{maximum}")
|
|
52
|
+
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _Cidr(value: object, minimum_prefix: int, label: str) -> str:
|
|
57
|
+
"""Require canonical IPv4 networks with an explicit bounded prefix."""
|
|
58
|
+
|
|
59
|
+
if not isinstance(value, str):
|
|
60
|
+
raise GatewayError(f"{label} must be a canonical IPv4 CIDR")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
network = ipaddress.IPv4Network(value, strict=True)
|
|
64
|
+
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise GatewayError(f"{label} must be a canonical IPv4 CIDR") from None
|
|
67
|
+
|
|
68
|
+
if str(network) != value or network.prefixlen < minimum_prefix:
|
|
69
|
+
raise GatewayError(f"{label} must be canonical and use prefix /{minimum_prefix} or narrower")
|
|
70
|
+
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _Cidrs(value: object, minimum_prefix: int, label: str) -> tuple[str, ...]:
|
|
75
|
+
"""Limit rule expansion and reject duplicate or overlapping address allowances."""
|
|
76
|
+
|
|
77
|
+
if not isinstance(value, tuple) or not 1 <= len(value) <= 16:
|
|
78
|
+
raise GatewayError(f"{label} must contain 1..16 immutable CIDRs")
|
|
79
|
+
|
|
80
|
+
cidrs = tuple(_Cidr(item, minimum_prefix, label) for item in value)
|
|
81
|
+
networks = tuple(ipaddress.IPv4Network(item) for item in cidrs)
|
|
82
|
+
|
|
83
|
+
if any(left.overlaps(right) for index, left in enumerate(networks) for right in networks[index + 1:]):
|
|
84
|
+
raise GatewayError(f"{label} must not contain duplicate or overlapping CIDRs")
|
|
85
|
+
|
|
86
|
+
return cidrs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _PublicKey(value: object) -> str:
|
|
90
|
+
"""Accept only a complete Ed25519 public wire key without comments or private data."""
|
|
91
|
+
|
|
92
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 256:
|
|
93
|
+
raise GatewayError("ssh_public_key must be a complete Ed25519 public key")
|
|
94
|
+
|
|
95
|
+
parts = value.split(" ")
|
|
96
|
+
|
|
97
|
+
if len(parts) != 2 or parts[0] != "ssh-ed25519":
|
|
98
|
+
raise GatewayError("ssh_public_key must contain only the Ed25519 type and public blob")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
blob = base64.b64decode(parts[1], validate=True)
|
|
102
|
+
expected = struct.pack(">I", 11) + b"ssh-ed25519" + struct.pack(">I", 32)
|
|
103
|
+
|
|
104
|
+
if len(blob) != len(expected) + 32 or not blob.startswith(expected):
|
|
105
|
+
raise ValueError("Invalid public wire key")
|
|
106
|
+
|
|
107
|
+
if base64.b64encode(blob).decode("ascii") != parts[1]:
|
|
108
|
+
raise ValueError("Noncanonical public key encoding")
|
|
109
|
+
|
|
110
|
+
except (ValueError, binascii.Error):
|
|
111
|
+
raise GatewayError("ssh_public_key must be a complete Ed25519 public key") from None
|
|
112
|
+
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _Username(value: object) -> str:
|
|
117
|
+
"""Require an explicit portable non-root account without coercing TOML types."""
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(value, str)
|
|
121
|
+
or _USERNAME_PATTERN.fullmatch(value) is None
|
|
122
|
+
or value == "root"
|
|
123
|
+
):
|
|
124
|
+
raise GatewayError("ssh_username must be a portable non-root account name")
|
|
125
|
+
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _Host(value: object) -> str:
|
|
130
|
+
"""Require a literal IPv4 or canonical lowercase DNS name without SSH tokens."""
|
|
131
|
+
|
|
132
|
+
if not isinstance(value, str) or _HOST_PATTERN.fullmatch(value) is None:
|
|
133
|
+
raise GatewayError("Host must be a literal IPv4 or lowercase DNS name")
|
|
134
|
+
|
|
135
|
+
if value.replace(".", "").isdigit():
|
|
136
|
+
try:
|
|
137
|
+
address = ipaddress.IPv4Address(value)
|
|
138
|
+
|
|
139
|
+
except ValueError:
|
|
140
|
+
raise GatewayError("Host must contain a canonical IPv4 address") from None
|
|
141
|
+
|
|
142
|
+
if str(address) != value or address.is_unspecified or address.is_multicast:
|
|
143
|
+
raise GatewayError("Host must contain a unicast IPv4 address")
|
|
144
|
+
|
|
145
|
+
elif any(
|
|
146
|
+
not label or len(label) > 63 or label.startswith("-") or label.endswith("-")
|
|
147
|
+
for label in value.split(".")
|
|
148
|
+
):
|
|
149
|
+
raise GatewayError("Host must contain a canonical lowercase DNS name")
|
|
150
|
+
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class GatewayTarget:
|
|
156
|
+
"""One exact TCP destination reachable through the SSH gateway."""
|
|
157
|
+
|
|
158
|
+
name: str
|
|
159
|
+
host: str
|
|
160
|
+
port: int
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
"""Exclude wildcards, socket paths, aliases, and unbounded destination ports."""
|
|
164
|
+
|
|
165
|
+
ValidateName(self.name, "target name")
|
|
166
|
+
_Host(self.host)
|
|
167
|
+
_Integer(self.port, 1, 65535, "target port")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class GatewayDevice:
|
|
172
|
+
"""One device public credential and explicitly bounded connection source networks."""
|
|
173
|
+
|
|
174
|
+
device_id: str
|
|
175
|
+
public_key: str = field(repr=False)
|
|
176
|
+
source_cidrs: tuple[str, ...]
|
|
177
|
+
|
|
178
|
+
def __post_init__(self) -> None:
|
|
179
|
+
"""Validate public credentials without resolving their corresponding private key."""
|
|
180
|
+
|
|
181
|
+
ValidateName(self.device_id, "device_id")
|
|
182
|
+
_PublicKey(self.public_key)
|
|
183
|
+
_Cidrs(self.source_cidrs, 24, "device source CIDRs")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class GatewayTransport:
|
|
188
|
+
"""A restricted SSH local-forward transport with separately declared device keys."""
|
|
189
|
+
|
|
190
|
+
username: str
|
|
191
|
+
targets: tuple[GatewayTarget, ...]
|
|
192
|
+
devices: tuple[GatewayDevice, ...]
|
|
193
|
+
|
|
194
|
+
def __post_init__(self) -> None:
|
|
195
|
+
"""Keep all transport expansion bounded and forbid ambiguous destinations or keys."""
|
|
196
|
+
|
|
197
|
+
_Username(self.username)
|
|
198
|
+
|
|
199
|
+
if not isinstance(self.targets, tuple) or not 1 <= len(self.targets) <= 8 or any(
|
|
200
|
+
not isinstance(item, GatewayTarget) for item in self.targets
|
|
201
|
+
):
|
|
202
|
+
raise GatewayError("Transport requires 1..8 immutable GatewayTarget values")
|
|
203
|
+
|
|
204
|
+
if not isinstance(self.devices, tuple) or not 1 <= len(self.devices) <= 16 or any(
|
|
205
|
+
not isinstance(item, GatewayDevice) for item in self.devices
|
|
206
|
+
):
|
|
207
|
+
raise GatewayError("Transport requires 1..16 immutable GatewayDevice values")
|
|
208
|
+
|
|
209
|
+
if len({item.name for item in self.targets}) != len(self.targets) or len({
|
|
210
|
+
(item.host, item.port) for item in self.targets
|
|
211
|
+
}) != len(self.targets):
|
|
212
|
+
raise GatewayError("Transport targets must have unique names and destinations")
|
|
213
|
+
|
|
214
|
+
if len({item.device_id for item in self.devices}) != len(self.devices) or len({
|
|
215
|
+
item.public_key for item in self.devices
|
|
216
|
+
}) != len(self.devices):
|
|
217
|
+
raise GatewayError("Transport devices must have unique names and public keys")
|
|
218
|
+
|
|
219
|
+
if sum(len(item.source_cidrs) for item in self.devices) > 32:
|
|
220
|
+
raise GatewayError("Transport device sources must not exceed 32 CIDRs")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class GatewayPort:
|
|
225
|
+
"""One explicit service allowance; an allowance does not install a listener."""
|
|
226
|
+
|
|
227
|
+
protocol: str
|
|
228
|
+
port: int
|
|
229
|
+
cidrs: tuple[str, ...]
|
|
230
|
+
|
|
231
|
+
def __post_init__(self) -> None:
|
|
232
|
+
"""Require supported transport labels, bounded ports, and explicit IPv4 sources."""
|
|
233
|
+
|
|
234
|
+
if not isinstance(self.protocol, str) or self.protocol not in {"tcp", "udp"}:
|
|
235
|
+
raise GatewayError("service protocol must be tcp or udp")
|
|
236
|
+
|
|
237
|
+
_Integer(self.port, 1, 65535, "service port")
|
|
238
|
+
_Cidrs(self.cidrs, 0, "service CIDRs")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, slots=True)
|
|
242
|
+
class GatewayProfile:
|
|
243
|
+
"""Explicit cloud placement and Ubuntu 24.04 cloud-init security intent."""
|
|
244
|
+
|
|
245
|
+
identity: StackIdentity
|
|
246
|
+
zone_id: str
|
|
247
|
+
image_id: str
|
|
248
|
+
subnet_cidr: str
|
|
249
|
+
ssh_public_key: str = field(repr=False)
|
|
250
|
+
management_cidrs: tuple[str, ...]
|
|
251
|
+
transport: GatewayTransport
|
|
252
|
+
service_ports: tuple[GatewayPort, ...] = ()
|
|
253
|
+
ssh_username: str = "gateway-admin"
|
|
254
|
+
ssh_port: int = 22
|
|
255
|
+
cores: int = 2
|
|
256
|
+
memory_gib: int = 2
|
|
257
|
+
boot_disk_gib: int = 20
|
|
258
|
+
schema_version: int = PROFILE_SCHEMA_VERSION
|
|
259
|
+
|
|
260
|
+
def __post_init__(self) -> None:
|
|
261
|
+
"""Validate construction independently of the TOML parser."""
|
|
262
|
+
|
|
263
|
+
ValidateSchemaVersion(self.schema_version)
|
|
264
|
+
|
|
265
|
+
if not isinstance(self.identity, StackIdentity):
|
|
266
|
+
raise GatewayError("identity must be a StackIdentity")
|
|
267
|
+
|
|
268
|
+
ValidateIdentifier(self.zone_id, "zone_id")
|
|
269
|
+
ValidateIdentifier(self.image_id, "image_id")
|
|
270
|
+
_Cidr(self.subnet_cidr, 16, "subnet_cidr")
|
|
271
|
+
network = ipaddress.IPv4Network(self.subnet_cidr)
|
|
272
|
+
private_ranges = tuple(ipaddress.IPv4Network(value) for value in (
|
|
273
|
+
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
|
274
|
+
))
|
|
275
|
+
|
|
276
|
+
if network.prefixlen > 28 or not any(network.subnet_of(item) for item in private_ranges):
|
|
277
|
+
raise GatewayError("subnet_cidr must be an RFC1918 network with prefix /16../28")
|
|
278
|
+
|
|
279
|
+
_PublicKey(self.ssh_public_key)
|
|
280
|
+
_Cidrs(self.management_cidrs, 24, "management CIDRs")
|
|
281
|
+
|
|
282
|
+
_Username(self.ssh_username)
|
|
283
|
+
|
|
284
|
+
if not isinstance(self.transport, GatewayTransport):
|
|
285
|
+
raise GatewayError("transport must be a GatewayTransport")
|
|
286
|
+
|
|
287
|
+
if self.transport.username == self.ssh_username or any(
|
|
288
|
+
item.public_key == self.ssh_public_key for item in self.transport.devices
|
|
289
|
+
):
|
|
290
|
+
raise GatewayError("Tunnel and administration accounts and public keys must be separate")
|
|
291
|
+
_Integer(self.ssh_port, 1, 65535, "ssh_port")
|
|
292
|
+
_Integer(self.cores, 2, 32, "cores")
|
|
293
|
+
_Integer(self.memory_gib, 1, 64, "memory_gib")
|
|
294
|
+
_Integer(self.boot_disk_gib, 10, 256, "boot_disk_gib")
|
|
295
|
+
|
|
296
|
+
if not isinstance(self.service_ports, tuple) or len(self.service_ports) > 8 or any(
|
|
297
|
+
not isinstance(item, GatewayPort) for item in self.service_ports
|
|
298
|
+
):
|
|
299
|
+
raise GatewayError("service_ports must contain at most eight GatewayPort values")
|
|
300
|
+
|
|
301
|
+
ports = {(item.protocol, item.port) for item in self.service_ports}
|
|
302
|
+
|
|
303
|
+
if len(ports) != len(self.service_ports) or ("tcp", self.ssh_port) in ports:
|
|
304
|
+
raise GatewayError("service ports must be unique and must not expose the SSH port")
|
|
305
|
+
|
|
306
|
+
if len(_SshCidrs(self)) + sum(len(item.cidrs) for item in self.service_ports) > 63:
|
|
307
|
+
raise GatewayError("Profile must reserve one egress rule within its 64-rule budget")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _ParseTransport(value: object) -> GatewayTransport:
|
|
311
|
+
"""Decode the concrete SSH transport without accepting inline private credentials."""
|
|
312
|
+
|
|
313
|
+
table = RequireTable(value, "transport")
|
|
314
|
+
ValidateFields(table, frozenset({"kind", "username", "targets", "devices"}), frozenset(), "transport")
|
|
315
|
+
|
|
316
|
+
if table["kind"] != "ssh-local-forward":
|
|
317
|
+
raise GatewayError("Only the ssh-local-forward transport is implemented")
|
|
318
|
+
|
|
319
|
+
if not isinstance(table["targets"], list) or not isinstance(table["devices"], list):
|
|
320
|
+
raise GatewayError("Transport targets and devices must be arrays")
|
|
321
|
+
|
|
322
|
+
targets: list[GatewayTarget] = []
|
|
323
|
+
devices: list[GatewayDevice] = []
|
|
324
|
+
|
|
325
|
+
for raw_target in table["targets"]:
|
|
326
|
+
target = RequireTable(raw_target, "target")
|
|
327
|
+
ValidateFields(target, frozenset({"name", "host", "port"}), frozenset(), "target")
|
|
328
|
+
targets.append(GatewayTarget(
|
|
329
|
+
name=ValidateName(target["name"], "target name"), host=_Host(target["host"]),
|
|
330
|
+
port=_Integer(target["port"], 1, 65535, "target port"),
|
|
331
|
+
))
|
|
332
|
+
|
|
333
|
+
for raw_device in table["devices"]:
|
|
334
|
+
device = RequireTable(raw_device, "device")
|
|
335
|
+
ValidateFields(device, frozenset({"device_id", "public_key", "source_cidrs"}), frozenset(), "device")
|
|
336
|
+
|
|
337
|
+
if not isinstance(device["source_cidrs"], list):
|
|
338
|
+
raise GatewayError("Device source_cidrs must be an array")
|
|
339
|
+
|
|
340
|
+
devices.append(GatewayDevice(
|
|
341
|
+
device_id=ValidateName(device["device_id"], "device_id"),
|
|
342
|
+
public_key=_PublicKey(device["public_key"]), source_cidrs=tuple(device["source_cidrs"]),
|
|
343
|
+
))
|
|
344
|
+
|
|
345
|
+
return GatewayTransport(_Username(table["username"]), tuple(targets), tuple(devices))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def ParseGatewayProfile(data: Mapping[str, object]) -> GatewayProfile:
|
|
349
|
+
"""Reject unknown settings, secret fields, and unversioned profile tables."""
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
ValidateFields(
|
|
353
|
+
data, frozenset({"schema_version", "profile", "guest_contract", "identity", "gateway", "transport"}),
|
|
354
|
+
frozenset({"service_ports"}), "gateway profile",
|
|
355
|
+
)
|
|
356
|
+
ValidateSchemaVersion(data["schema_version"])
|
|
357
|
+
|
|
358
|
+
if data["profile"] != PROFILE_NAME or data["guest_contract"] != "ubuntu-24.04-cloud-init":
|
|
359
|
+
raise GatewayError("profile and guest_contract must select the supported server foundation")
|
|
360
|
+
|
|
361
|
+
gateway = RequireTable(data["gateway"], "gateway")
|
|
362
|
+
ValidateFields(gateway, _GATEWAY_FIELDS, _OPTIONAL_GATEWAY_FIELDS, "gateway")
|
|
363
|
+
raw_cidrs = gateway["management_cidrs"]
|
|
364
|
+
raw_ports = data.get("service_ports", [])
|
|
365
|
+
|
|
366
|
+
if not isinstance(raw_cidrs, list) or not isinstance(raw_ports, list):
|
|
367
|
+
raise GatewayError("management_cidrs and service_ports must be arrays")
|
|
368
|
+
|
|
369
|
+
service_ports: list[GatewayPort] = []
|
|
370
|
+
|
|
371
|
+
for raw_port in raw_ports:
|
|
372
|
+
port = RequireTable(raw_port, "service port")
|
|
373
|
+
ValidateFields(port, frozenset({"protocol", "port", "cidrs"}), frozenset(), "service port")
|
|
374
|
+
|
|
375
|
+
if not isinstance(port["protocol"], str) or not isinstance(port["cidrs"], list):
|
|
376
|
+
raise GatewayError("service port requires a protocol and CIDR array")
|
|
377
|
+
|
|
378
|
+
service_ports.append(GatewayPort(
|
|
379
|
+
protocol=port["protocol"],
|
|
380
|
+
port=_Integer(port["port"], 1, 65535, "service port"),
|
|
381
|
+
cidrs=tuple(port["cidrs"]),
|
|
382
|
+
))
|
|
383
|
+
|
|
384
|
+
return GatewayProfile(
|
|
385
|
+
identity=ParseIdentity(data["identity"]),
|
|
386
|
+
zone_id=ValidateIdentifier(gateway["zone_id"], "zone_id"),
|
|
387
|
+
image_id=ValidateIdentifier(gateway["image_id"], "image_id"),
|
|
388
|
+
subnet_cidr=_Cidr(gateway["subnet_cidr"], 16, "subnet_cidr"),
|
|
389
|
+
ssh_public_key=_PublicKey(gateway["ssh_public_key"]),
|
|
390
|
+
management_cidrs=tuple(raw_cidrs),
|
|
391
|
+
transport=_ParseTransport(data["transport"]),
|
|
392
|
+
service_ports=tuple(service_ports),
|
|
393
|
+
ssh_username=_Username(gateway.get("ssh_username", "gateway-admin")),
|
|
394
|
+
ssh_port=_Integer(gateway.get("ssh_port", 22), 1, 65535, "ssh_port"),
|
|
395
|
+
cores=_Integer(gateway.get("cores", 2), 2, 32, "cores"),
|
|
396
|
+
memory_gib=_Integer(gateway.get("memory_gib", 2), 1, 64, "memory_gib"),
|
|
397
|
+
boot_disk_gib=_Integer(gateway.get("boot_disk_gib", 20), 10, 256, "boot_disk_gib"),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
except ContractError as error:
|
|
401
|
+
raise GatewayError(str(error)) from None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def LoadGatewayProfile(path: str | Path) -> GatewayProfile:
|
|
405
|
+
"""Read a bounded explicit profile without consulting credentials or environment defaults."""
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
with Path(path).open("rb") as stream:
|
|
409
|
+
raw = stream.read(MAX_PROFILE_BYTES + 1)
|
|
410
|
+
|
|
411
|
+
if len(raw) > MAX_PROFILE_BYTES:
|
|
412
|
+
raise GatewayError("Gateway profile exceeds its bounded input size")
|
|
413
|
+
|
|
414
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
415
|
+
|
|
416
|
+
except (OSError, ValueError, RecursionError):
|
|
417
|
+
raise GatewayError("Unable to read a valid bounded gateway profile") from None
|
|
418
|
+
|
|
419
|
+
return ParseGatewayProfile(data)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass(frozen=True, slots=True)
|
|
423
|
+
class GatewayPlan:
|
|
424
|
+
"""Immutable composition input; serialized lifecycle plans bind an exported artifact."""
|
|
425
|
+
|
|
426
|
+
profile: GatewayProfile
|
|
427
|
+
|
|
428
|
+
def __post_init__(self) -> None:
|
|
429
|
+
"""Reject unsupported plan construction before any resource serialization."""
|
|
430
|
+
|
|
431
|
+
if not isinstance(self.profile, GatewayProfile):
|
|
432
|
+
raise GatewayError("profile must be a GatewayProfile")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def CompileGateway(profile: GatewayProfile) -> GatewayPlan:
|
|
436
|
+
"""Compile validated profile intent without performing filesystem or provider operations."""
|
|
437
|
+
|
|
438
|
+
plan = GatewayPlan(profile)
|
|
439
|
+
|
|
440
|
+
for logical_id in ("network", "subnet", "firewall", "address", "boot-disk", "instance"):
|
|
441
|
+
ValidateName(profile.identity.stack + "-" + logical_id, "resource name")
|
|
442
|
+
|
|
443
|
+
return plan
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _Firewall(profile: GatewayProfile) -> str:
|
|
447
|
+
"""Render bounded IPv4 ingress and deny packet routing for SSH application forwarding."""
|
|
448
|
+
|
|
449
|
+
lines = [
|
|
450
|
+
"#!/usr/sbin/nft -f", "flush ruleset", "table inet flayer_filter {",
|
|
451
|
+
" chain input {", " type filter hook input priority 0; policy drop;",
|
|
452
|
+
" ct state invalid drop", " ct state established,related accept",
|
|
453
|
+
' iifname "lo" accept', " udp sport 67 udp dport 68 accept",
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
for cidr in _SshCidrs(profile):
|
|
457
|
+
lines.append(f" ip saddr {cidr} tcp dport {profile.ssh_port} accept")
|
|
458
|
+
|
|
459
|
+
for service in profile.service_ports:
|
|
460
|
+
for cidr in service.cidrs:
|
|
461
|
+
lines.append(f" ip saddr {cidr} {service.protocol} dport {service.port} accept")
|
|
462
|
+
|
|
463
|
+
lines.extend([
|
|
464
|
+
" }", " chain forward {", " type filter hook forward priority 0; policy drop;",
|
|
465
|
+
" }", "}", "",
|
|
466
|
+
])
|
|
467
|
+
|
|
468
|
+
return "\n".join(lines)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _SshCidrs(profile: GatewayProfile) -> tuple[str, ...]:
|
|
472
|
+
"""Deduplicate SSH connection networks while preserving per-key source authorization."""
|
|
473
|
+
|
|
474
|
+
return tuple(dict.fromkeys((
|
|
475
|
+
*profile.management_cidrs,
|
|
476
|
+
*(cidr for device in profile.transport.devices for cidr in device.source_cidrs),
|
|
477
|
+
)))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _CloudInit(profile: GatewayProfile) -> bytes:
|
|
481
|
+
"""Generate nonsecret initialization for the explicitly selected Ubuntu guest contract."""
|
|
482
|
+
|
|
483
|
+
ssh_configuration = "\n".join([
|
|
484
|
+
f"Port {profile.ssh_port}", "UsePAM yes", "PermitRootLogin no", "PasswordAuthentication no",
|
|
485
|
+
"KbdInteractiveAuthentication no", "PubkeyAuthentication yes",
|
|
486
|
+
"StrictModes yes", "AuthorizedKeysFile /etc/ssh/f-layer-admin-keys",
|
|
487
|
+
"AuthorizedKeysCommand none", "TrustedUserCAKeys none", "AuthorizedPrincipalsFile none",
|
|
488
|
+
"AuthenticationMethods publickey", "AllowUsers " + " ".join((
|
|
489
|
+
*(f"{profile.ssh_username}@{cidr}" for cidr in profile.management_cidrs),
|
|
490
|
+
*(f"{profile.transport.username}@{cidr}" for device in profile.transport.devices for cidr in device.source_cidrs),
|
|
491
|
+
)),
|
|
492
|
+
"MaxAuthTries 3", "LoginGraceTime 30", "X11Forwarding no", "AllowAgentForwarding no",
|
|
493
|
+
"AllowTcpForwarding no", "AllowStreamLocalForwarding no", "PermitTunnel no",
|
|
494
|
+
"GatewayPorts no", "", f"Match User {profile.transport.username}",
|
|
495
|
+
" AuthorizedKeysFile /etc/ssh/f-layer-tunnel-keys",
|
|
496
|
+
" AllowTcpForwarding local", " PermitOpen " + " ".join(
|
|
497
|
+
f"{target.host}:{target.port}" for target in profile.transport.targets
|
|
498
|
+
),
|
|
499
|
+
" PermitListen none", " MaxSessions 0", " PermitTTY no", " PermitUserRC no", "",
|
|
500
|
+
])
|
|
501
|
+
sysctl_configuration = "\n".join([
|
|
502
|
+
"net.ipv4.ip_forward=0", "net.ipv4.conf.all.accept_redirects=0",
|
|
503
|
+
"net.ipv4.conf.default.accept_redirects=0", "net.ipv4.conf.all.send_redirects=0",
|
|
504
|
+
"net.ipv4.conf.default.send_redirects=0", "net.ipv4.conf.all.accept_source_route=0",
|
|
505
|
+
"net.ipv4.conf.default.accept_source_route=0", "net.ipv6.conf.all.disable_ipv6=1",
|
|
506
|
+
"net.ipv6.conf.default.disable_ipv6=1", "",
|
|
507
|
+
])
|
|
508
|
+
device_keys = [
|
|
509
|
+
','.join((
|
|
510
|
+
'from="' + ','.join(device.source_cidrs) + '"', "restrict", "port-forwarding",
|
|
511
|
+
*(f'permitopen="{target.host}:{target.port}"' for target in profile.transport.targets),
|
|
512
|
+
)) + " " + device.public_key for device in profile.transport.devices
|
|
513
|
+
]
|
|
514
|
+
bootstrap = "\n".join([
|
|
515
|
+
"#!/bin/sh", "set -eu",
|
|
516
|
+
f"if getent passwd {profile.transport.username} >/dev/null; then",
|
|
517
|
+
" echo 'Dedicated tunnel account already exists; initialization refused' >&2",
|
|
518
|
+
" exit 1", "else", " lookup_status=$?",
|
|
519
|
+
' if [ "$lookup_status" -ne 2 ]; then',
|
|
520
|
+
" echo 'Unable to verify dedicated tunnel account absence' >&2",
|
|
521
|
+
" exit 1", " fi", "fi",
|
|
522
|
+
f"useradd --create-home --shell /bin/bash --user-group {profile.transport.username}",
|
|
523
|
+
f"usermod --lock {profile.transport.username}",
|
|
524
|
+
"install -d -m 0755 /run/sshd", "sshd -t",
|
|
525
|
+
"nft --check --file /etc/nftables.conf", "sysctl --system",
|
|
526
|
+
"systemctl enable --now nftables", "systemctl enable --now unattended-upgrades",
|
|
527
|
+
"systemctl disable --now ssh.socket", "systemctl daemon-reload",
|
|
528
|
+
"systemctl enable --now ssh.service", "systemctl restart ssh.service",
|
|
529
|
+
"install -d -m 0700 /var/lib/f-layer", "touch /var/lib/f-layer/bootstrap-ready", "",
|
|
530
|
+
])
|
|
531
|
+
files = [
|
|
532
|
+
("/etc/ssh/sshd_config", "0644", ssh_configuration),
|
|
533
|
+
("/etc/ssh/f-layer-admin-keys", "0644", profile.ssh_public_key + "\n"),
|
|
534
|
+
("/etc/ssh/f-layer-tunnel-keys", "0644", "\n".join(device_keys) + "\n"),
|
|
535
|
+
("/etc/sysctl.d/90-f-layer.conf", "0644", sysctl_configuration),
|
|
536
|
+
("/etc/nftables.conf", "0644", _Firewall(profile)),
|
|
537
|
+
("/usr/local/sbin/f-layer-bootstrap", "0700", bootstrap),
|
|
538
|
+
]
|
|
539
|
+
data = {
|
|
540
|
+
"ssh_pwauth": False, "disable_root": True,
|
|
541
|
+
"users": [{
|
|
542
|
+
"name": profile.ssh_username, "groups": ["sudo"], "shell": "/bin/bash",
|
|
543
|
+
"lock_passwd": True, "sudo": "ALL=(ALL) NOPASSWD:ALL",
|
|
544
|
+
"ssh_authorized_keys": [profile.ssh_public_key],
|
|
545
|
+
}],
|
|
546
|
+
"package_update": True, "package_upgrade": False,
|
|
547
|
+
"packages": ["nftables", "unattended-upgrades"],
|
|
548
|
+
"write_files": [{
|
|
549
|
+
"path": path, "owner": "root:root", "permissions": permissions, "content": content,
|
|
550
|
+
} for path, permissions, content in files],
|
|
551
|
+
"runcmd": [["/bin/sh", "/usr/local/sbin/f-layer-bootstrap"]],
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return ("#cloud-config\n" + json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def BuildServerBundle(profile: GatewayProfile) -> ArtifactBundle:
|
|
558
|
+
"""Build initialization bytes without resolving secrets, uploading, or claiming readiness."""
|
|
559
|
+
|
|
560
|
+
CompileGateway(profile)
|
|
561
|
+
|
|
562
|
+
return ArtifactBundle(
|
|
563
|
+
identity=profile.identity, kind="server", name=profile.identity.stack,
|
|
564
|
+
files=(Artifact("server-cloud-init.json", _CloudInit(profile), sensitive=False),),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _SshPath(value: str | Path) -> str:
|
|
569
|
+
"""Permit explicit literal key and trust paths without expansion or directive injection."""
|
|
570
|
+
|
|
571
|
+
text = str(value)
|
|
572
|
+
path = Path(text)
|
|
573
|
+
windows_path = PureWindowsPath(text)
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
not (path.is_absolute() or windows_path.is_absolute())
|
|
577
|
+
or ".." in path.parts or ".." in windows_path.parts or len(text) > 4096
|
|
578
|
+
or any(character in text for character in ('"', "'", "\\", "%", "$", "~"))
|
|
579
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in text)
|
|
580
|
+
):
|
|
581
|
+
raise GatewayError("SSH references require explicit absolute literal paths")
|
|
582
|
+
|
|
583
|
+
return text
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def BuildSshDeviceBundle(
|
|
587
|
+
profile: GatewayProfile, device_id: str, *, endpoint: str,
|
|
588
|
+
identity_file: str | Path, known_hosts_file: str | Path, local_ports: tuple[int, ...],
|
|
589
|
+
) -> ArtifactBundle:
|
|
590
|
+
"""Generate usable local TCP forwards with caller-provided key and trusted-host references."""
|
|
591
|
+
|
|
592
|
+
CompileGateway(profile)
|
|
593
|
+
ValidateName(device_id, "device_id")
|
|
594
|
+
_Host(endpoint)
|
|
595
|
+
key_path = _SshPath(identity_file)
|
|
596
|
+
trust_path = _SshPath(known_hosts_file)
|
|
597
|
+
device = next((item for item in profile.transport.devices if item.device_id == device_id), None)
|
|
598
|
+
|
|
599
|
+
if device is None:
|
|
600
|
+
raise GatewayError("Device must be declared in the compiled gateway transport")
|
|
601
|
+
|
|
602
|
+
if not isinstance(local_ports, tuple) or len(local_ports) != len(profile.transport.targets):
|
|
603
|
+
raise GatewayError("local_ports must contain one immutable port for each declared target")
|
|
604
|
+
|
|
605
|
+
for port in local_ports:
|
|
606
|
+
_Integer(port, 1024, 65535, "local port")
|
|
607
|
+
|
|
608
|
+
if len(set(local_ports)) != len(local_ports):
|
|
609
|
+
raise GatewayError("Local forward ports must be unique")
|
|
610
|
+
|
|
611
|
+
lines = [
|
|
612
|
+
f"Host {device_id}", f" HostName {endpoint}", f" Port {profile.ssh_port}",
|
|
613
|
+
f" User {profile.transport.username}", f' IdentityFile "{key_path}"',
|
|
614
|
+
f' UserKnownHostsFile "{trust_path}"', " GlobalKnownHostsFile none",
|
|
615
|
+
" StrictHostKeyChecking yes", " VerifyHostKeyDNS no", " UpdateHostKeys no",
|
|
616
|
+
" KnownHostsCommand none", " IdentitiesOnly yes", " IdentityAgent none",
|
|
617
|
+
" PreferredAuthentications publickey", " PasswordAuthentication no",
|
|
618
|
+
" KbdInteractiveAuthentication no", " ForwardAgent no", " ForwardX11 no",
|
|
619
|
+
" RequestTTY no", " SessionType none", " ProxyCommand none", " ProxyJump none",
|
|
620
|
+
" PermitLocalCommand no", " CanonicalizeHostname no", " ExitOnForwardFailure yes",
|
|
621
|
+
" ConnectTimeout 10", " ConnectionAttempts 1", " ServerAliveInterval 15",
|
|
622
|
+
" ServerAliveCountMax 3",
|
|
623
|
+
]
|
|
624
|
+
|
|
625
|
+
for port, target in zip(local_ports, profile.transport.targets, strict=True):
|
|
626
|
+
lines.append(f" LocalForward 127.0.0.1:{port} {target.host}:{target.port}")
|
|
627
|
+
|
|
628
|
+
descriptor = {
|
|
629
|
+
"schema_version": 1, "device_id": device_id, "transport": "ssh-local-forward",
|
|
630
|
+
"status": "prepared-unverified", "identity": {
|
|
631
|
+
key: getattr(profile.identity, key) for key in sorted(_IDENTITY_FIELDS)
|
|
632
|
+
},
|
|
633
|
+
"public_key_sha256": hashlib.sha256(device.public_key.encode("ascii")).hexdigest(),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return ArtifactBundle(profile.identity, "device", device_id, (
|
|
637
|
+
Artifact("ssh-client.conf", ("\n".join(lines) + "\n").encode("utf-8"), sensitive=False),
|
|
638
|
+
Artifact("device-info.json", (json.dumps(descriptor, sort_keys=True) + "\n").encode("utf-8"), sensitive=False),
|
|
639
|
+
))
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def RenderPlan(plan: GatewayPlan, *, user_data_file: str | Path) -> str:
|
|
643
|
+
"""Bind exact generated bytes to a lifecycle TOML plan without importing future orchestration."""
|
|
644
|
+
|
|
645
|
+
if not isinstance(plan, GatewayPlan):
|
|
646
|
+
raise GatewayError("plan must be a GatewayPlan")
|
|
647
|
+
|
|
648
|
+
profile = plan.profile
|
|
649
|
+
artifact_path = Path(user_data_file)
|
|
650
|
+
expected = _CloudInit(profile)
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
actual = ReadOwnedArtifactFile(
|
|
654
|
+
artifact_path, profile.identity, kind="server", name=profile.identity.stack,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
except ContractError:
|
|
658
|
+
raise GatewayError("Prepared server artifact ownership or integrity is invalid") from None
|
|
659
|
+
|
|
660
|
+
if actual != expected:
|
|
661
|
+
raise GatewayError("Initialization artifact does not match the compiled gateway profile")
|
|
662
|
+
|
|
663
|
+
def Quote(value: str) -> str:
|
|
664
|
+
"""Keep Unicode scalar paths valid TOML instead of emitting surrogate escapes."""
|
|
665
|
+
|
|
666
|
+
return json.dumps(value, ensure_ascii=False)
|
|
667
|
+
|
|
668
|
+
lines = ["schema_version = 1", f"profile = {Quote(PROFILE_NAME)}", "", "[identity]"]
|
|
669
|
+
|
|
670
|
+
for key in sorted(_IDENTITY_FIELDS):
|
|
671
|
+
lines.append(f"{key} = {Quote(getattr(profile.identity, key))}")
|
|
672
|
+
|
|
673
|
+
def AddResource(
|
|
674
|
+
logical_id: str, kind: str, dependencies: tuple[str, ...], properties: Mapping[str, object],
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Append deterministic resource intent with no observed provider identifiers."""
|
|
677
|
+
|
|
678
|
+
lines.extend([
|
|
679
|
+
"", "[[resources]]", f"logical_id = {Quote(logical_id)}", f"kind = {Quote(kind)}",
|
|
680
|
+
f"name = {Quote(ValidateName(profile.identity.stack + '-' + logical_id, 'resource name'))}",
|
|
681
|
+
"dependencies = [" + ", ".join(Quote(item) for item in dependencies) + "]",
|
|
682
|
+
"[resources.parameters]",
|
|
683
|
+
])
|
|
684
|
+
|
|
685
|
+
for key, value in properties.items():
|
|
686
|
+
if isinstance(value, str):
|
|
687
|
+
rendered = Quote(value)
|
|
688
|
+
|
|
689
|
+
elif type(value) is int:
|
|
690
|
+
rendered = str(value)
|
|
691
|
+
|
|
692
|
+
else:
|
|
693
|
+
raise GatewayError("Resource properties must be explicit scalar values")
|
|
694
|
+
|
|
695
|
+
lines.append(f"{key} = {rendered}")
|
|
696
|
+
|
|
697
|
+
AddResource("network", "network", (), {})
|
|
698
|
+
AddResource("subnet", "subnet", ("network",), {
|
|
699
|
+
"zone_id": profile.zone_id, "ipv4_cidr": profile.subnet_cidr,
|
|
700
|
+
"network_dependency": "network",
|
|
701
|
+
})
|
|
702
|
+
AddResource("firewall", "security-group", ("network",), {"network_dependency": "network"})
|
|
703
|
+
rules = [("tcp", profile.ssh_port, cidr) for cidr in _SshCidrs(profile)]
|
|
704
|
+
rules.extend((service.protocol, service.port, cidr) for service in profile.service_ports for cidr in service.cidrs)
|
|
705
|
+
|
|
706
|
+
for protocol, port, cidr in rules:
|
|
707
|
+
lines.extend([
|
|
708
|
+
"[[resources.parameters.rules]]", 'direction = "ingress"', f"protocol = {Quote(protocol)}",
|
|
709
|
+
f"from_port = {port}", f"to_port = {port}", f"cidr = {Quote(cidr)}",
|
|
710
|
+
])
|
|
711
|
+
|
|
712
|
+
lines.extend([
|
|
713
|
+
"[[resources.parameters.rules]]", 'direction = "egress"', 'protocol = "any"',
|
|
714
|
+
'cidr = "0.0.0.0/0"',
|
|
715
|
+
])
|
|
716
|
+
AddResource("address", "address", (), {"zone_id": profile.zone_id})
|
|
717
|
+
AddResource("boot-disk", "disk", (), {
|
|
718
|
+
"zone_id": profile.zone_id, "image_id": profile.image_id, "size_gib": profile.boot_disk_gib,
|
|
719
|
+
})
|
|
720
|
+
AddResource("instance", "instance", ("subnet", "firewall", "address", "boot-disk"), {
|
|
721
|
+
"zone_id": profile.zone_id, "cores": profile.cores,
|
|
722
|
+
"memory_gib": profile.memory_gib, "boot_disk_dependency": "boot-disk",
|
|
723
|
+
"subnet_dependency": "subnet", "security_group_dependency": "firewall",
|
|
724
|
+
"address_dependency": "address", "ssh_public_key": profile.ssh_public_key,
|
|
725
|
+
"ssh_username": profile.ssh_username, "user_data_file": str(artifact_path.absolute()),
|
|
726
|
+
"user_data_sha256": hashlib.sha256(expected).hexdigest(),
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
return "\n".join(lines) + "\n"
|