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,596 @@
|
|
|
1
|
+
"""Prepare a bounded static HTTP page for an explicitly selected fresh Ubuntu guest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import hashlib
|
|
9
|
+
import html
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
import struct
|
|
16
|
+
import tomllib
|
|
17
|
+
import unicodedata
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from flayer.core.contracts import (
|
|
23
|
+
IDENTITY_FIELDS,
|
|
24
|
+
ContractError,
|
|
25
|
+
ParseIdentity,
|
|
26
|
+
RequireTable,
|
|
27
|
+
StackIdentity,
|
|
28
|
+
ValidateFields,
|
|
29
|
+
ValidateIdentifier,
|
|
30
|
+
ValidateName,
|
|
31
|
+
ValidateSchemaVersion,
|
|
32
|
+
)
|
|
33
|
+
from flayer.profiles.artifacts import (
|
|
34
|
+
Artifact,
|
|
35
|
+
ArtifactBundle,
|
|
36
|
+
ReadOwnedArtifactFile,
|
|
37
|
+
RemoveArtifactBundle,
|
|
38
|
+
WriteArtifactBundle,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
PROFILE_NAME = "static-site"
|
|
42
|
+
GUEST_CONTRACT = "ubuntu-24.04-cloud-init"
|
|
43
|
+
MAX_PROFILE_BYTES = 32768
|
|
44
|
+
CLOUD_INIT_FILENAME = "static-site-cloud-init.json"
|
|
45
|
+
PLAN_FILENAME = "deployment-plan.toml"
|
|
46
|
+
_USERNAME_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,31}\Z")
|
|
47
|
+
_PLACEMENT_FIELDS = frozenset({"zone_id", "image_id", "subnet_cidr"})
|
|
48
|
+
_SIZING_FIELDS = frozenset({"cores", "memory_gib", "boot_disk_gib"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StaticSiteError(ContractError):
|
|
52
|
+
"""The static site violates its bounded profile or prepared artifact contract."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _Integer(value: object, minimum: int, maximum: int, label: str) -> int:
|
|
56
|
+
"""Reject coercion and constrain resource requests before external operations."""
|
|
57
|
+
|
|
58
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
59
|
+
raise StaticSiteError(f"{label} must be an integer within {minimum}..{maximum}")
|
|
60
|
+
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _Cidr(value: object, minimum_prefix: int, label: str) -> str:
|
|
65
|
+
"""Require an explicit canonical IPv4 network without host bits or IPv6."""
|
|
66
|
+
|
|
67
|
+
if not isinstance(value, str):
|
|
68
|
+
raise StaticSiteError(f"{label} must be a canonical IPv4 CIDR")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
network = ipaddress.IPv4Network(value, strict=True)
|
|
72
|
+
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise StaticSiteError(f"{label} must be a canonical IPv4 CIDR") from None
|
|
75
|
+
|
|
76
|
+
if str(network) != value or network.prefixlen < minimum_prefix:
|
|
77
|
+
raise StaticSiteError(f"{label} must use canonical prefix /{minimum_prefix} or narrower")
|
|
78
|
+
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _Cidrs(value: object, minimum_prefix: int, label: str) -> tuple[str, ...]:
|
|
83
|
+
"""Bound rule expansion and reject duplicate or overlapping source networks."""
|
|
84
|
+
|
|
85
|
+
if not isinstance(value, tuple) or not 1 <= len(value) <= 16:
|
|
86
|
+
raise StaticSiteError(f"{label} must contain 1..16 immutable CIDRs")
|
|
87
|
+
|
|
88
|
+
cidrs = tuple(_Cidr(item, minimum_prefix, label) for item in value)
|
|
89
|
+
networks = tuple(ipaddress.IPv4Network(item) for item in cidrs)
|
|
90
|
+
|
|
91
|
+
if any(left.overlaps(right) for index, left in enumerate(networks)
|
|
92
|
+
for right in networks[index + 1:]):
|
|
93
|
+
raise StaticSiteError(f"{label} must not contain duplicate or overlapping CIDRs")
|
|
94
|
+
|
|
95
|
+
return cidrs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _PublicKey(value: object) -> str:
|
|
99
|
+
"""Accept one canonical Ed25519 public wire key without comments or options."""
|
|
100
|
+
|
|
101
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 256:
|
|
102
|
+
raise StaticSiteError("admin public_key must be a complete Ed25519 public key")
|
|
103
|
+
|
|
104
|
+
parts = value.split(" ")
|
|
105
|
+
|
|
106
|
+
if len(parts) != 2 or parts[0] != "ssh-ed25519":
|
|
107
|
+
raise StaticSiteError("admin public_key must contain only its type and public blob")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
blob = base64.b64decode(parts[1], validate=True)
|
|
111
|
+
expected = struct.pack(">I", 11) + b"ssh-ed25519" + struct.pack(">I", 32)
|
|
112
|
+
|
|
113
|
+
if len(blob) != len(expected) + 32 or not blob.startswith(expected):
|
|
114
|
+
raise ValueError("Invalid public wire key")
|
|
115
|
+
|
|
116
|
+
if base64.b64encode(blob).decode("ascii") != parts[1]:
|
|
117
|
+
raise ValueError("Noncanonical public encoding")
|
|
118
|
+
|
|
119
|
+
except (ValueError, binascii.Error):
|
|
120
|
+
raise StaticSiteError("admin public_key must be a complete Ed25519 public key") from None
|
|
121
|
+
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _Username(value: object) -> str:
|
|
126
|
+
"""Require an explicit portable non-root administrator without command interpolation."""
|
|
127
|
+
|
|
128
|
+
if (not isinstance(value, str) or _USERNAME_PATTERN.fullmatch(value) is None
|
|
129
|
+
or value in {"root", "www-data", "nginx"}):
|
|
130
|
+
raise StaticSiteError("admin username must be portable and separate from root and web accounts")
|
|
131
|
+
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _Text(value: object, maximum: int, label: str, *, multiline: bool = False) -> str:
|
|
136
|
+
"""Accept publishable bounded text, excluding controls and invalid Unicode scalars."""
|
|
137
|
+
|
|
138
|
+
if not isinstance(value, str) or not value.strip():
|
|
139
|
+
raise StaticSiteError(f"{label} must contain nonempty plain text")
|
|
140
|
+
|
|
141
|
+
if any(unicodedata.category(character).startswith("C")
|
|
142
|
+
and not (multiline and character == "\n") for character in value):
|
|
143
|
+
raise StaticSiteError(f"{label} must not contain control characters")
|
|
144
|
+
|
|
145
|
+
if len(value.encode("utf-8")) > maximum:
|
|
146
|
+
raise StaticSiteError(f"{label} must not exceed {maximum} UTF-8 bytes")
|
|
147
|
+
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class StaticSiteProfile:
|
|
153
|
+
"""Explicit Yandex placement and one escaped static page with separate SSH authority."""
|
|
154
|
+
|
|
155
|
+
identity: StackIdentity
|
|
156
|
+
zone_id: str
|
|
157
|
+
image_id: str
|
|
158
|
+
subnet_cidr: str
|
|
159
|
+
admin_username: str
|
|
160
|
+
admin_public_key: str = field(repr=False)
|
|
161
|
+
admin_source_cidrs: tuple[str, ...]
|
|
162
|
+
http_source_cidrs: tuple[str, ...]
|
|
163
|
+
title: str = field(repr=False)
|
|
164
|
+
body: str = field(repr=False)
|
|
165
|
+
cores: int = 2
|
|
166
|
+
memory_gib: int = 2
|
|
167
|
+
boot_disk_gib: int = 20
|
|
168
|
+
schema_version: int = 1
|
|
169
|
+
guest_contract: str = GUEST_CONTRACT
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
"""Keep direct construction as strict as the versioned TOML input boundary."""
|
|
173
|
+
|
|
174
|
+
ValidateSchemaVersion(self.schema_version)
|
|
175
|
+
|
|
176
|
+
if not isinstance(self.identity, StackIdentity) or self.identity.provider != "yandex-cloud":
|
|
177
|
+
raise StaticSiteError("identity must explicitly select the supported yandex-cloud provider")
|
|
178
|
+
|
|
179
|
+
if self.guest_contract != GUEST_CONTRACT:
|
|
180
|
+
raise StaticSiteError("guest_contract must select ubuntu-24.04-cloud-init")
|
|
181
|
+
|
|
182
|
+
ValidateIdentifier(self.zone_id, "zone_id")
|
|
183
|
+
ValidateIdentifier(self.image_id, "image_id")
|
|
184
|
+
network = ipaddress.IPv4Network(_Cidr(self.subnet_cidr, 16, "subnet_cidr"))
|
|
185
|
+
private_ranges = tuple(ipaddress.IPv4Network(value) for value in (
|
|
186
|
+
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
if network.prefixlen > 28 or not any(network.subnet_of(item) for item in private_ranges):
|
|
190
|
+
raise StaticSiteError("subnet_cidr must be RFC1918 with prefix /16../28")
|
|
191
|
+
|
|
192
|
+
_Username(self.admin_username)
|
|
193
|
+
_PublicKey(self.admin_public_key)
|
|
194
|
+
_Cidrs(self.admin_source_cidrs, 24, "admin source CIDRs")
|
|
195
|
+
_Cidrs(self.http_source_cidrs, 0, "HTTP source CIDRs")
|
|
196
|
+
_Text(self.title, 160, "site title")
|
|
197
|
+
_Text(self.body, 8192, "site body", multiline=True)
|
|
198
|
+
_Integer(self.cores, 2, 32, "cores")
|
|
199
|
+
_Integer(self.memory_gib, 1, 64, "memory_gib")
|
|
200
|
+
_Integer(self.boot_disk_gib, 10, 256, "boot_disk_gib")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def ParseStaticSiteProfile(data: Mapping[str, object]) -> StaticSiteProfile:
|
|
204
|
+
"""Decode a strict concrete profile without accepting commands or private credentials."""
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
ValidateFields(data, frozenset({
|
|
208
|
+
"schema_version", "profile", "guest_contract", "identity", "placement", "admin", "site",
|
|
209
|
+
}), frozenset(), "static-site profile")
|
|
210
|
+
ValidateSchemaVersion(data["schema_version"])
|
|
211
|
+
|
|
212
|
+
if data["profile"] != PROFILE_NAME or data["guest_contract"] != GUEST_CONTRACT:
|
|
213
|
+
raise StaticSiteError("profile and guest_contract must select the supported static site")
|
|
214
|
+
|
|
215
|
+
placement = RequireTable(data["placement"], "placement")
|
|
216
|
+
admin = RequireTable(data["admin"], "admin")
|
|
217
|
+
site = RequireTable(data["site"], "site")
|
|
218
|
+
ValidateFields(placement, _PLACEMENT_FIELDS, _SIZING_FIELDS, "placement")
|
|
219
|
+
ValidateFields(admin, frozenset({"username", "public_key", "source_cidrs"}),
|
|
220
|
+
frozenset(), "admin")
|
|
221
|
+
ValidateFields(site, frozenset({"title", "body", "http_source_cidrs"}),
|
|
222
|
+
frozenset(), "site")
|
|
223
|
+
|
|
224
|
+
if not isinstance(admin["source_cidrs"], list) or not isinstance(site["http_source_cidrs"], list):
|
|
225
|
+
raise StaticSiteError("Admin and HTTP source CIDRs must be explicit arrays")
|
|
226
|
+
|
|
227
|
+
return StaticSiteProfile(
|
|
228
|
+
identity=ParseIdentity(data["identity"]),
|
|
229
|
+
zone_id=ValidateIdentifier(placement["zone_id"], "zone_id"),
|
|
230
|
+
image_id=ValidateIdentifier(placement["image_id"], "image_id"),
|
|
231
|
+
subnet_cidr=_Cidr(placement["subnet_cidr"], 16, "subnet_cidr"),
|
|
232
|
+
admin_username=_Username(admin["username"]),
|
|
233
|
+
admin_public_key=_PublicKey(admin["public_key"]),
|
|
234
|
+
admin_source_cidrs=tuple(admin["source_cidrs"]),
|
|
235
|
+
http_source_cidrs=tuple(site["http_source_cidrs"]),
|
|
236
|
+
title=_Text(site["title"], 160, "site title"),
|
|
237
|
+
body=_Text(site["body"], 8192, "site body", multiline=True),
|
|
238
|
+
cores=_Integer(placement.get("cores", 2), 2, 32, "cores"),
|
|
239
|
+
memory_gib=_Integer(placement.get("memory_gib", 2), 1, 64, "memory_gib"),
|
|
240
|
+
boot_disk_gib=_Integer(placement.get("boot_disk_gib", 20), 10, 256, "boot_disk_gib"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
except ContractError as error:
|
|
244
|
+
raise StaticSiteError(str(error)) from None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def LoadStaticSiteProfile(path: str | Path) -> StaticSiteProfile:
|
|
248
|
+
"""Read bounded regular UTF-8 TOML without following symlinks or blocking on special files."""
|
|
249
|
+
|
|
250
|
+
descriptor: int | None = None
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
profile_path = Path(path).absolute()
|
|
254
|
+
|
|
255
|
+
if ".." in profile_path.parts or any(item.is_symlink() for item in (profile_path, *profile_path.parents)):
|
|
256
|
+
raise StaticSiteError("Profile paths must not contain traversal or symlinks")
|
|
257
|
+
|
|
258
|
+
descriptor = os.open(profile_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
259
|
+
| getattr(os, "O_NONBLOCK", 0))
|
|
260
|
+
info = os.fstat(descriptor)
|
|
261
|
+
|
|
262
|
+
if not stat.S_ISREG(info.st_mode) or not 1 <= info.st_size <= MAX_PROFILE_BYTES:
|
|
263
|
+
raise StaticSiteError("Profile input must be a bounded regular file")
|
|
264
|
+
|
|
265
|
+
with os.fdopen(descriptor, "rb") as source:
|
|
266
|
+
descriptor = None
|
|
267
|
+
raw = source.read(MAX_PROFILE_BYTES + 1)
|
|
268
|
+
|
|
269
|
+
if len(raw) > MAX_PROFILE_BYTES:
|
|
270
|
+
raise StaticSiteError("Static-site profile exceeds its bounded input size")
|
|
271
|
+
|
|
272
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
273
|
+
|
|
274
|
+
except (OSError, ValueError, RecursionError):
|
|
275
|
+
raise StaticSiteError("Unable to read a valid bounded static-site profile") from None
|
|
276
|
+
|
|
277
|
+
finally:
|
|
278
|
+
if descriptor is not None:
|
|
279
|
+
os.close(descriptor)
|
|
280
|
+
|
|
281
|
+
return ParseStaticSiteProfile(data)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True, slots=True)
|
|
285
|
+
class StaticSitePlan:
|
|
286
|
+
"""Immutable profile intent whose serialized plan binds an exact owned initialization."""
|
|
287
|
+
|
|
288
|
+
profile: StaticSiteProfile
|
|
289
|
+
|
|
290
|
+
def __post_init__(self) -> None:
|
|
291
|
+
"""Reject unsupported values before resource or artifact serialization."""
|
|
292
|
+
|
|
293
|
+
if not isinstance(self.profile, StaticSiteProfile):
|
|
294
|
+
raise StaticSiteError("profile must be a StaticSiteProfile")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def CompileStaticSite(profile: StaticSiteProfile) -> StaticSitePlan:
|
|
298
|
+
"""Validate composed names without invoking storage, lifecycle, or provider mutation."""
|
|
299
|
+
|
|
300
|
+
plan = StaticSitePlan(profile)
|
|
301
|
+
|
|
302
|
+
for logical_id in ("network", "subnet", "firewall", "address", "boot-disk", "instance", "plan"):
|
|
303
|
+
ValidateName(profile.identity.stack + "-" + logical_id, "resource or bundle name")
|
|
304
|
+
|
|
305
|
+
return plan
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _Page(profile: StaticSiteProfile) -> str:
|
|
309
|
+
"""Escape all profile text into fixed markup with no template or script execution."""
|
|
310
|
+
|
|
311
|
+
title = html.escape(profile.title, quote=True)
|
|
312
|
+
body = html.escape(profile.body, quote=True)
|
|
313
|
+
|
|
314
|
+
return "\n".join([
|
|
315
|
+
"<!doctype html>", '<html lang="en">', "<head>", '<meta charset="utf-8">',
|
|
316
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
317
|
+
f"<title>{title}</title>", "</head>", "<body>", f"<h1>{title}</h1>",
|
|
318
|
+
f'<p style="white-space: pre-wrap">{body}</p>', "</body>", "</html>", "",
|
|
319
|
+
])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _Firewall(profile: StaticSiteProfile) -> str:
|
|
323
|
+
"""Allow declared SSH and HTTP sources while denying routed traffic and other ingress."""
|
|
324
|
+
|
|
325
|
+
lines = [
|
|
326
|
+
"#!/usr/sbin/nft -f", "flush ruleset", "table inet flayer_filter {", " chain input {",
|
|
327
|
+
" type filter hook input priority 0; policy drop;", " ct state invalid drop",
|
|
328
|
+
" ct state established,related accept", ' iifname "lo" accept',
|
|
329
|
+
" udp sport 67 udp dport 68 accept",
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
for cidr in profile.admin_source_cidrs:
|
|
333
|
+
lines.append(f" ip saddr {cidr} tcp dport 22 accept")
|
|
334
|
+
|
|
335
|
+
for cidr in profile.http_source_cidrs:
|
|
336
|
+
lines.append(f" ip saddr {cidr} tcp dport 80 accept")
|
|
337
|
+
|
|
338
|
+
lines.extend([
|
|
339
|
+
" }", " chain forward {", " type filter hook forward priority 0; policy drop;",
|
|
340
|
+
" }", "}", "",
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
return "\n".join(lines)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _CloudInit(profile: StaticSiteProfile) -> bytes:
|
|
347
|
+
"""Generate only fixed nonsecret guest operations and escaped publishable page text."""
|
|
348
|
+
|
|
349
|
+
ssh_configuration = "\n".join([
|
|
350
|
+
"Port 22", "UsePAM yes", "PermitRootLogin no", "PasswordAuthentication no",
|
|
351
|
+
"KbdInteractiveAuthentication no", "PubkeyAuthentication yes", "StrictModes yes",
|
|
352
|
+
"AuthorizedKeysFile /etc/ssh/f-layer-static-admin-keys", "AuthorizedKeysCommand none",
|
|
353
|
+
"TrustedUserCAKeys none", "AuthorizedPrincipalsFile none", "AuthenticationMethods publickey",
|
|
354
|
+
"AllowUsers " + " ".join(f"{profile.admin_username}@{cidr}" for cidr in profile.admin_source_cidrs),
|
|
355
|
+
"MaxAuthTries 3", "LoginGraceTime 30", "X11Forwarding no", "AllowAgentForwarding no",
|
|
356
|
+
"AllowTcpForwarding no", "AllowStreamLocalForwarding no", "PermitTunnel no",
|
|
357
|
+
"GatewayPorts no", "PermitUserRC no", "",
|
|
358
|
+
])
|
|
359
|
+
nginx_configuration = "\n".join([
|
|
360
|
+
"user www-data;", "worker_processes auto;", "pid /run/nginx.pid;",
|
|
361
|
+
"events { worker_connections 128; }", "http {", " default_type text/html;",
|
|
362
|
+
" server_tokens off;", " access_log off;", " server {", " listen 80 default_server;",
|
|
363
|
+
" server_name _;", " root /var/www/f-layer-static;", " autoindex off;",
|
|
364
|
+
" add_header X-Content-Type-Options nosniff always;",
|
|
365
|
+
" add_header Content-Security-Policy \"default-src 'none'; style-src 'unsafe-inline'\" always;",
|
|
366
|
+
" location = / { try_files /index.html =404; }",
|
|
367
|
+
" location = /index.html { try_files /index.html =404; }",
|
|
368
|
+
" location / { return 404; }", " }", "}", "",
|
|
369
|
+
])
|
|
370
|
+
sysctl_configuration = "\n".join([
|
|
371
|
+
"net.ipv4.ip_forward=0", "net.ipv4.conf.all.accept_redirects=0",
|
|
372
|
+
"net.ipv4.conf.default.accept_redirects=0", "net.ipv4.conf.all.send_redirects=0",
|
|
373
|
+
"net.ipv4.conf.default.send_redirects=0", "net.ipv4.conf.all.accept_source_route=0",
|
|
374
|
+
"net.ipv4.conf.default.accept_source_route=0", "net.ipv6.conf.all.disable_ipv6=1",
|
|
375
|
+
"net.ipv6.conf.default.disable_ipv6=1", "",
|
|
376
|
+
])
|
|
377
|
+
bootstrap = "\n".join([
|
|
378
|
+
"#!/bin/sh", "set -eu", "install -d -m 0755 /run/sshd",
|
|
379
|
+
"chown root:root /etc/ssh/f-layer-static-admin-keys",
|
|
380
|
+
"chmod 0644 /etc/ssh/f-layer-static-admin-keys",
|
|
381
|
+
"chown root:root /var/www/f-layer-static /var/www/f-layer-static/index.html",
|
|
382
|
+
"chmod 0755 /var/www/f-layer-static", "chmod 0644 /var/www/f-layer-static/index.html",
|
|
383
|
+
"sshd -t", "nft --check --file /etc/nftables.conf", "nginx -t -c /etc/nginx/nginx.conf",
|
|
384
|
+
"sysctl --system", "systemctl enable --now nftables",
|
|
385
|
+
"systemctl enable --now unattended-upgrades", "systemctl disable --now ssh.socket",
|
|
386
|
+
"systemctl daemon-reload", "systemctl enable --now ssh.service",
|
|
387
|
+
"systemctl restart ssh.service", "systemctl enable --now nginx.service",
|
|
388
|
+
"systemctl restart nginx.service", "",
|
|
389
|
+
])
|
|
390
|
+
files = [
|
|
391
|
+
("/etc/ssh/sshd_config", "0644", ssh_configuration),
|
|
392
|
+
("/etc/ssh/f-layer-static-admin-keys", "0644", profile.admin_public_key + "\n"),
|
|
393
|
+
("/etc/nginx/nginx.conf", "0644", nginx_configuration),
|
|
394
|
+
("/var/www/f-layer-static/index.html", "0644", _Page(profile)),
|
|
395
|
+
("/etc/sysctl.d/90-f-layer-static.conf", "0644", sysctl_configuration),
|
|
396
|
+
("/etc/nftables.conf", "0644", _Firewall(profile)),
|
|
397
|
+
("/usr/local/sbin/f-layer-static-bootstrap", "0700", bootstrap),
|
|
398
|
+
]
|
|
399
|
+
data = {
|
|
400
|
+
"ssh_pwauth": False, "disable_root": True,
|
|
401
|
+
"users": [{
|
|
402
|
+
"name": profile.admin_username, "groups": ["sudo"], "shell": "/bin/bash",
|
|
403
|
+
"lock_passwd": True, "sudo": "ALL=(ALL) NOPASSWD:ALL",
|
|
404
|
+
}],
|
|
405
|
+
"package_update": True, "package_upgrade": False,
|
|
406
|
+
"packages": ["nginx", "nftables", "unattended-upgrades"],
|
|
407
|
+
"write_files": [{
|
|
408
|
+
"path": path, "owner": "root:root", "permissions": permissions, "content": content,
|
|
409
|
+
} for path, permissions, content in files],
|
|
410
|
+
"runcmd": [["/bin/sh", "/usr/local/sbin/f-layer-static-bootstrap"]],
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return ("#cloud-config\n" + json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def BuildStaticSiteBundle(profile: StaticSiteProfile) -> ArtifactBundle:
|
|
417
|
+
"""Create public initialization bytes without credentials or guest readiness claims."""
|
|
418
|
+
|
|
419
|
+
CompileStaticSite(profile)
|
|
420
|
+
|
|
421
|
+
return ArtifactBundle(profile.identity, "server", profile.identity.stack, (
|
|
422
|
+
Artifact(CLOUD_INIT_FILENAME, _CloudInit(profile), sensitive=False),
|
|
423
|
+
))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def RenderStaticSitePlan(plan: StaticSitePlan, *, user_data_file: str | Path) -> str:
|
|
427
|
+
"""Bind the existing six-resource lifecycle contract to verified owned initialization."""
|
|
428
|
+
|
|
429
|
+
if not isinstance(plan, StaticSitePlan):
|
|
430
|
+
raise StaticSiteError("plan must be a StaticSitePlan")
|
|
431
|
+
|
|
432
|
+
profile = plan.profile
|
|
433
|
+
artifact_path = Path(user_data_file)
|
|
434
|
+
expected = _CloudInit(profile)
|
|
435
|
+
|
|
436
|
+
if artifact_path.name != CLOUD_INIT_FILENAME:
|
|
437
|
+
raise StaticSiteError("Initialization must reference the exact static-site artifact filename")
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
actual = ReadOwnedArtifactFile(artifact_path, profile.identity, kind="server",
|
|
441
|
+
name=profile.identity.stack)
|
|
442
|
+
|
|
443
|
+
except ContractError:
|
|
444
|
+
raise StaticSiteError("Prepared static-site artifact ownership or integrity is invalid") from None
|
|
445
|
+
|
|
446
|
+
if actual != expected:
|
|
447
|
+
raise StaticSiteError("Initialization artifact does not match the compiled static-site profile")
|
|
448
|
+
|
|
449
|
+
def Quote(value: str) -> str:
|
|
450
|
+
"""Serialize Unicode scalar strings as valid TOML without path expansion."""
|
|
451
|
+
|
|
452
|
+
return json.dumps(value, ensure_ascii=False)
|
|
453
|
+
|
|
454
|
+
lines = ["schema_version = 1", f"profile = {Quote(PROFILE_NAME)}", "", "[identity]"]
|
|
455
|
+
|
|
456
|
+
for key in sorted(IDENTITY_FIELDS):
|
|
457
|
+
lines.append(f"{key} = {Quote(getattr(profile.identity, key))}")
|
|
458
|
+
|
|
459
|
+
def AddResource(
|
|
460
|
+
logical_id: str, kind: str, dependencies: tuple[str, ...], parameters: Mapping[str, object],
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Render deterministic scalar resource intent without observed cloud identifiers."""
|
|
463
|
+
|
|
464
|
+
lines.extend([
|
|
465
|
+
"", "[[resources]]", f"logical_id = {Quote(logical_id)}", f"kind = {Quote(kind)}",
|
|
466
|
+
f"name = {Quote(ValidateName(profile.identity.stack + '-' + logical_id, 'resource name'))}",
|
|
467
|
+
"dependencies = [" + ", ".join(Quote(item) for item in dependencies) + "]",
|
|
468
|
+
"[resources.parameters]",
|
|
469
|
+
])
|
|
470
|
+
|
|
471
|
+
for key, value in parameters.items():
|
|
472
|
+
if isinstance(value, str):
|
|
473
|
+
rendered = Quote(value)
|
|
474
|
+
|
|
475
|
+
elif type(value) is int:
|
|
476
|
+
rendered = str(value)
|
|
477
|
+
|
|
478
|
+
else:
|
|
479
|
+
raise StaticSiteError("Resource parameters must be explicit scalar values")
|
|
480
|
+
|
|
481
|
+
lines.append(f"{key} = {rendered}")
|
|
482
|
+
|
|
483
|
+
AddResource("network", "network", (), {})
|
|
484
|
+
AddResource("subnet", "subnet", ("network",), {
|
|
485
|
+
"zone_id": profile.zone_id, "ipv4_cidr": profile.subnet_cidr,
|
|
486
|
+
"network_dependency": "network",
|
|
487
|
+
})
|
|
488
|
+
AddResource("firewall", "security-group", ("network",), {"network_dependency": "network"})
|
|
489
|
+
|
|
490
|
+
for port, cidrs in ((22, profile.admin_source_cidrs), (80, profile.http_source_cidrs)):
|
|
491
|
+
for cidr in cidrs:
|
|
492
|
+
lines.extend([
|
|
493
|
+
"[[resources.parameters.rules]]", 'direction = "ingress"', 'protocol = "tcp"',
|
|
494
|
+
f"from_port = {port}", f"to_port = {port}", f"cidr = {Quote(cidr)}",
|
|
495
|
+
])
|
|
496
|
+
|
|
497
|
+
lines.extend([
|
|
498
|
+
"[[resources.parameters.rules]]", 'direction = "egress"', 'protocol = "any"',
|
|
499
|
+
'cidr = "0.0.0.0/0"',
|
|
500
|
+
])
|
|
501
|
+
AddResource("address", "address", (), {"zone_id": profile.zone_id})
|
|
502
|
+
AddResource("boot-disk", "disk", (), {
|
|
503
|
+
"zone_id": profile.zone_id, "image_id": profile.image_id, "size_gib": profile.boot_disk_gib,
|
|
504
|
+
})
|
|
505
|
+
AddResource("instance", "instance", ("subnet", "firewall", "address", "boot-disk"), {
|
|
506
|
+
"zone_id": profile.zone_id, "cores": profile.cores, "memory_gib": profile.memory_gib,
|
|
507
|
+
"boot_disk_dependency": "boot-disk", "subnet_dependency": "subnet",
|
|
508
|
+
"security_group_dependency": "firewall", "address_dependency": "address",
|
|
509
|
+
"ssh_public_key": profile.admin_public_key, "ssh_username": profile.admin_username,
|
|
510
|
+
"user_data_file": str(artifact_path.absolute()),
|
|
511
|
+
"user_data_sha256": hashlib.sha256(expected).hexdigest(),
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
return "\n".join(lines) + "\n"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True, slots=True)
|
|
518
|
+
class StaticSitePreparation:
|
|
519
|
+
"""Local bundle references for a prepared plan; deployment and readiness remain unverified."""
|
|
520
|
+
|
|
521
|
+
server_directory: Path
|
|
522
|
+
plan_directory: Path
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def PlanFile(self) -> Path:
|
|
526
|
+
"""Locate the prepared TOML inside its separately owned private plan bundle."""
|
|
527
|
+
|
|
528
|
+
return self.plan_directory / PLAN_FILENAME
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def PrepareStaticSite(profile: StaticSiteProfile, *, artifact_root: str | Path) -> StaticSitePreparation:
|
|
532
|
+
"""Publish exclusive owned bundles; preserve a verified plan after publication failure."""
|
|
533
|
+
|
|
534
|
+
plan = CompileStaticSite(profile)
|
|
535
|
+
server_bundle = BuildStaticSiteBundle(profile)
|
|
536
|
+
server_directory = WriteArtifactBundle(artifact_root, server_bundle)
|
|
537
|
+
plan_text: str | None = None
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
plan_text = RenderStaticSitePlan(plan, user_data_file=server_directory / CLOUD_INIT_FILENAME)
|
|
541
|
+
plan_directory = WriteArtifactBundle(artifact_root, ArtifactBundle(
|
|
542
|
+
profile.identity, "server", profile.identity.stack + "-plan", (
|
|
543
|
+
Artifact(PLAN_FILENAME, plan_text.encode("utf-8"), sensitive=False),
|
|
544
|
+
),
|
|
545
|
+
))
|
|
546
|
+
|
|
547
|
+
except BaseException:
|
|
548
|
+
published_plan = False
|
|
549
|
+
|
|
550
|
+
if plan_text is not None:
|
|
551
|
+
try:
|
|
552
|
+
actual = ReadOwnedArtifactFile(
|
|
553
|
+
Path(artifact_root) / ("server-" + profile.identity.stack + "-plan") / PLAN_FILENAME,
|
|
554
|
+
profile.identity, kind="server", name=profile.identity.stack + "-plan",
|
|
555
|
+
)
|
|
556
|
+
published_plan = actual == plan_text.encode("utf-8")
|
|
557
|
+
|
|
558
|
+
except ContractError:
|
|
559
|
+
pass
|
|
560
|
+
|
|
561
|
+
if not published_plan:
|
|
562
|
+
RemoveArtifactBundle(
|
|
563
|
+
artifact_root, profile.identity, kind="server", name=profile.identity.stack,
|
|
564
|
+
expected_bundle=server_bundle,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
raise
|
|
568
|
+
|
|
569
|
+
return StaticSitePreparation(server_directory, plan_directory)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def Main(argv: Sequence[str] | None = None) -> int:
|
|
573
|
+
"""Prepare bounded local artifacts only; never instantiate or invoke a cloud provider."""
|
|
574
|
+
|
|
575
|
+
parser = argparse.ArgumentParser(description="Prepare an owned static-site lifecycle plan locally")
|
|
576
|
+
parser.add_argument("profile_file", help="Explicit version-one static-site TOML profile")
|
|
577
|
+
parser.add_argument("--artifact-root", required=True, help="Private POSIX artifact root")
|
|
578
|
+
arguments = parser.parse_args(argv)
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
prepared = PrepareStaticSite(LoadStaticSiteProfile(arguments.profile_file),
|
|
582
|
+
artifact_root=arguments.artifact_root)
|
|
583
|
+
|
|
584
|
+
except ContractError:
|
|
585
|
+
print(json.dumps({"status": "failed", "reason": "Profile or owned artifact preparation failed"}))
|
|
586
|
+
|
|
587
|
+
return 1
|
|
588
|
+
|
|
589
|
+
print(json.dumps({"status": "prepared-unverified", "plan_file": str(prepared.PlanFile.absolute())},
|
|
590
|
+
sort_keys=True))
|
|
591
|
+
|
|
592
|
+
return 0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
if __name__ == "__main__":
|
|
596
|
+
raise SystemExit(Main())
|