f-layer 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. f_layer-2.0.0.dist-info/METADATA +232 -0
  2. f_layer-2.0.0.dist-info/RECORD +45 -0
  3. f_layer-2.0.0.dist-info/WHEEL +4 -0
  4. f_layer-2.0.0.dist-info/entry_points.txt +2 -0
  5. f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
  6. flayer/__init__.py +5 -0
  7. flayer/__main__.py +247 -0
  8. flayer/_vpn_guest.py +130 -0
  9. flayer/core/__init__.py +1 -0
  10. flayer/core/config.py +198 -0
  11. flayer/core/contracts.py +114 -0
  12. flayer/core/lifecycle.py +767 -0
  13. flayer/core/state.py +351 -0
  14. flayer/diagnostics/__init__.py +20 -0
  15. flayer/diagnostics/benchmark.py +272 -0
  16. flayer/diagnostics/contracts.py +167 -0
  17. flayer/diagnostics/health.py +154 -0
  18. flayer/diagnostics/reporting.py +24 -0
  19. flayer/extensions/__init__.py +25 -0
  20. flayer/extensions/builtins.py +165 -0
  21. flayer/extensions/contracts.py +196 -0
  22. flayer/extensions/discovery.py +152 -0
  23. flayer/extensions/registry.py +347 -0
  24. flayer/guest.py +270 -0
  25. flayer/integrations/__init__.py +23 -0
  26. flayer/integrations/adapters.py +92 -0
  27. flayer/integrations/contracts.py +241 -0
  28. flayer/integrations/wire.py +273 -0
  29. flayer/profiles/__init__.py +33 -0
  30. flayer/profiles/_amnezia_export.py +176 -0
  31. flayer/profiles/_vless_guest.py +432 -0
  32. flayer/profiles/amneziawg.py +555 -0
  33. flayer/profiles/artifacts.py +609 -0
  34. flayer/profiles/gateway.py +729 -0
  35. flayer/profiles/static_site.py +596 -0
  36. flayer/profiles/vless.py +422 -0
  37. flayer/profiles/vpn.py +524 -0
  38. flayer/providers/__init__.py +1 -0
  39. flayer/providers/contracts.py +207 -0
  40. flayer/providers/lifecycle.py +177 -0
  41. flayer/providers/yandex.py +387 -0
  42. flayer/providers/yandex_lifecycle.py +781 -0
  43. flayer/py.typed +0 -0
  44. flayer/vpn_export.py +72 -0
  45. flayer/vpn_project.py +828 -0
@@ -0,0 +1,555 @@
1
+ """AmneziaWG 3.1 preparation with private keys and a pinned Ubuntu guest installer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import ipaddress
9
+ import json
10
+ import secrets
11
+ from dataclasses import asdict, dataclass, field
12
+ from textwrap import dedent
13
+
14
+ from flayer.profiles.artifacts import Artifact
15
+ from flayer.profiles.vpn import (
16
+ BuildVpnDeviceBundle,
17
+ BuildVpnServerBundle,
18
+ PreparedVpnTransport,
19
+ ValidateVpnTransport,
20
+ VpnCapabilities,
21
+ VpnEndpoint,
22
+ VpnError,
23
+ VpnProfile,
24
+ )
25
+
26
+ AMNEZIAWG_CAPABILITIES = VpnCapabilities("amneziawg", "ip-tunnel", ("full", "split"))
27
+ AMNEZIAWG_GO_VERSION = "v3.1.20260828"
28
+ AMNEZIAWG_GO_REVISION = "b5928efb6ca19f0153958460c3d141f04abc5c2e"
29
+ AMNEZIAWG_TOOLS_REVISION = "ee0f0a9aa34ff0a0da4b3433b9512781cfe02843"
30
+ GO_VERSION = "1.27.2"
31
+ GO_LINUX_AMD64_SHA256 = "ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5"
32
+ _PRIVATE_NETWORKS = tuple(ipaddress.IPv4Network(item) for item in (
33
+ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
34
+ ))
35
+
36
+
37
+ def _Key(value: object) -> str:
38
+ """Validate one canonical, nonzero 32-byte key without echoing rejected secrets."""
39
+
40
+ try:
41
+ if not isinstance(value, str):
42
+ raise ValueError
43
+
44
+ raw = base64.b64decode(value, validate=True)
45
+
46
+ if len(raw) != 32 or raw == bytes(32) or base64.b64encode(raw).decode() != value:
47
+ raise ValueError
48
+
49
+ except (ValueError, binascii.Error):
50
+ raise VpnError("AmneziaWG keys must be canonical nonzero 32-byte base64 values") from None
51
+
52
+ return value
53
+
54
+
55
+ def _PrivateKey() -> str:
56
+ """Generate a WireGuard-compatible Curve25519 private key using cryptography."""
57
+
58
+ try:
59
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
60
+
61
+ except ImportError:
62
+ raise VpnError("AmneziaWG requires the f-layer[vpn] optional dependency") from None
63
+
64
+ return base64.b64encode(X25519PrivateKey.generate().private_bytes_raw()).decode("ascii")
65
+
66
+
67
+ def _PublicKey(private_key: str) -> str:
68
+ """Derive a peer public key without invoking shell tools or logging private material."""
69
+
70
+ try:
71
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
72
+
73
+ except ImportError:
74
+ raise VpnError("AmneziaWG requires the f-layer[vpn] optional dependency") from None
75
+
76
+ key = X25519PrivateKey.from_private_bytes(base64.b64decode(_Key(private_key)))
77
+
78
+ return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class AmneziaWgDeviceKey:
83
+ """One device credential, excluded from diagnostic object representations."""
84
+
85
+ device_id: str
86
+ private_key: str = field(repr=False)
87
+
88
+ def __post_init__(self) -> None:
89
+ """Validate credential bytes and the public portable device name."""
90
+
91
+ from flayer.core.contracts import ValidateName
92
+
93
+ ValidateName(self.device_id, "device_id")
94
+ _Key(self.private_key)
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class AmneziaWgSecrets:
99
+ """Private preparation input kept out of generic configuration, state, and repr."""
100
+
101
+ server_private_key: str = field(repr=False)
102
+ header_protection_key: str = field(repr=False)
103
+ device_private_keys: tuple[AmneziaWgDeviceKey, ...] = field(repr=False)
104
+
105
+ def __post_init__(self) -> None:
106
+ """Require an immutable, bounded set of distinct private credentials."""
107
+
108
+ _Key(self.server_private_key)
109
+ _Key(self.header_protection_key)
110
+
111
+ if (
112
+ not isinstance(self.device_private_keys, tuple)
113
+ or not 1 <= len(self.device_private_keys) <= 16
114
+ or any(not isinstance(item, AmneziaWgDeviceKey) for item in self.device_private_keys)
115
+ or len({item.device_id for item in self.device_private_keys}) != len(self.device_private_keys)
116
+ ):
117
+ raise VpnError("AmneziaWG secrets require 1..16 unique immutable device keys")
118
+
119
+ keys = (self.server_private_key, self.header_protection_key) + tuple(
120
+ item.private_key for item in self.device_private_keys
121
+ )
122
+
123
+ if len(set(keys)) != len(keys):
124
+ raise VpnError("AmneziaWG server, header, and device keys must be distinct")
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class AmneziaWgSettings:
129
+ """Bounded IPv4 tunnel subnet and conservative client packet size."""
130
+
131
+ tunnel_cidr: str = "10.66.0.0/24"
132
+ mtu: int = 1280
133
+
134
+ def __post_init__(self) -> None:
135
+ """Reserve the first subnet host for the server and reject unsuitable subnets."""
136
+
137
+ try:
138
+ network = ipaddress.IPv4Network(self.tunnel_cidr, strict=True)
139
+
140
+ if (
141
+ str(network) != self.tunnel_cidr or not 16 <= network.prefixlen <= 28
142
+ or not any(network.subnet_of(item) for item in _PRIVATE_NETWORKS)
143
+ ):
144
+ raise ValueError
145
+
146
+ except (TypeError, ValueError):
147
+ raise VpnError("AmneziaWG tunnel_cidr must be a canonical RFC1918 /16../28") from None
148
+
149
+ if type(self.mtu) is not int or not 1280 <= self.mtu <= 1380:
150
+ raise VpnError("AmneziaWG MTU must be an integer within 1280..1380")
151
+
152
+
153
+ def GenerateAmneziaWgSecrets(
154
+ profile: VpnProfile, previous: AmneziaWgSecrets | None = None,
155
+ *, rotate_device_ids: tuple[str, ...] = (),
156
+ ) -> AmneziaWgSecrets:
157
+ """Keep unchanged credentials while explicitly adding, rotating, or removing devices."""
158
+
159
+ ValidateVpnTransport(profile, AMNEZIAWG_CAPABILITIES)
160
+ device_ids = {item.device_id for item in profile.devices}
161
+
162
+ if (
163
+ not isinstance(rotate_device_ids, tuple)
164
+ or any(not isinstance(item, str) or item not in device_ids for item in rotate_device_ids)
165
+ or len(set(rotate_device_ids)) != len(rotate_device_ids)
166
+ or (previous is not None and not isinstance(previous, AmneziaWgSecrets))
167
+ ):
168
+ raise VpnError("AmneziaWG rotation requires unique currently declared device IDs")
169
+
170
+ old = {} if previous is None else {
171
+ item.device_id: item.private_key for item in previous.device_private_keys
172
+ }
173
+ device_keys = tuple(AmneziaWgDeviceKey(
174
+ item.device_id,
175
+ old[item.device_id] if item.device_id in old and item.device_id not in rotate_device_ids
176
+ else _PrivateKey(),
177
+ ) for item in profile.devices)
178
+
179
+ return AmneziaWgSecrets(
180
+ _PrivateKey() if previous is None else previous.server_private_key,
181
+ base64.b64encode(secrets.token_bytes(32)).decode("ascii")
182
+ if previous is None else previous.header_protection_key,
183
+ device_keys,
184
+ )
185
+
186
+
187
+ def EncodeAmneziaWgSecrets(secret_values: AmneziaWgSecrets) -> bytes:
188
+ """Serialize controller-only credentials for an owned private material bundle."""
189
+
190
+ if not isinstance(secret_values, AmneziaWgSecrets):
191
+ raise VpnError("AmneziaWG material must be validated private credentials")
192
+
193
+ data = {"schema_version": 1, **asdict(secret_values)}
194
+
195
+ return (json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
196
+
197
+
198
+ def _UniqueObject(pairs: list[tuple[str, object]]) -> dict[str, object]:
199
+ """Reject duplicate JSON fields before they can silently replace credential material."""
200
+
201
+ result: dict[str, object] = {}
202
+
203
+ for key, value in pairs:
204
+ if key in result:
205
+ raise ValueError
206
+
207
+ result[key] = value
208
+
209
+ return result
210
+
211
+
212
+ def DecodeAmneziaWgSecrets(content: bytes) -> AmneziaWgSecrets:
213
+ """Validate bounded private material without returning rejected bytes in errors."""
214
+
215
+ try:
216
+ if not isinstance(content, bytes) or not 1 <= len(content) <= 16384:
217
+ raise ValueError
218
+
219
+ data = json.loads(content.decode("utf-8"), object_pairs_hook=_UniqueObject)
220
+
221
+ if (
222
+ not isinstance(data, dict)
223
+ or set(data) != {"schema_version", "server_private_key", "header_protection_key", "device_private_keys"}
224
+ or type(data["schema_version"]) is not int or data["schema_version"] != 1
225
+ or not isinstance(data["device_private_keys"], list)
226
+ ):
227
+ raise ValueError
228
+
229
+ keys = []
230
+
231
+ for item in data["device_private_keys"]:
232
+ if not isinstance(item, dict) or set(item) != {"device_id", "private_key"}:
233
+ raise ValueError
234
+
235
+ keys.append(AmneziaWgDeviceKey(item["device_id"], item["private_key"]))
236
+
237
+ values = AmneziaWgSecrets(data["server_private_key"], data["header_protection_key"], tuple(keys))
238
+
239
+ except (ValueError, TypeError, KeyError):
240
+ raise VpnError("AmneziaWG private material is invalid or unsupported") from None
241
+
242
+ return values
243
+
244
+
245
+ def AmneziaWgServiceName(profile: VpnProfile) -> str:
246
+ """Derive a stable guest service name from complete stack ownership, not credentials."""
247
+
248
+ payload = json.dumps(asdict(profile.identity), sort_keys=True, separators=(",", ":"))
249
+
250
+ return "flayer-awg-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
251
+
252
+
253
+ def _Parameters(secret_values: AmneziaWgSecrets) -> list[str]:
254
+ """Use compatible AWG 3.1 header protection and bounded variable padding on both ends."""
255
+
256
+ return [
257
+ "Jc = 4", "Jmin = 40", "Jmax = 70", "S1 = 32", "S2 = 32", "S3 = 32", "S4 = 32",
258
+ "H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4",
259
+ f"HeaderProtectionKey = {secret_values.header_protection_key}",
260
+ "ContentPaddingAddition = 0-64", "RandomTrailers = on",
261
+ ]
262
+
263
+
264
+ def _Firewall(profile: VpnProfile, interface: str, table: str) -> str:
265
+ """Limit forwarding to declared peer addresses and destinations and provide IPv4 NAT."""
266
+
267
+ sources = ", ".join(str(item.ipv4_address) for item in profile.devices)
268
+ destinations = ", ".join(profile.routes.ipv4_cidrs)
269
+
270
+ return "\n".join([
271
+ f"table ip {table} {{",
272
+ " chain forward {",
273
+ " type filter hook forward priority -10; policy accept;",
274
+ f' iifname "{interface}" oifname "{interface}" drop',
275
+ f' iifname "{interface}" ip daddr {{ 0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4 }} drop',
276
+ f' iifname "{interface}" ip saddr {{ {sources} }} ip daddr {{ {destinations} }} accept',
277
+ f' oifname "{interface}" ip daddr {{ {sources} }} ct state established,related accept',
278
+ f' iifname "{interface}" drop', f' oifname "{interface}" drop',
279
+ " }", " chain postrouting {",
280
+ " type nat hook postrouting priority srcnat; policy accept;",
281
+ f' iifname "{interface}" ip saddr {{ {sources} }} ip daddr {{ {destinations} }} masquerade',
282
+ " }", "}", "",
283
+ ])
284
+
285
+
286
+ def _NetworkScript(base: str, interface: str, table: str, settings: AmneziaWgSettings) -> str:
287
+ """Configure the foreground tunnel and replace only this stack's firewall table."""
288
+
289
+ network = ipaddress.IPv4Network(settings.tunnel_cidr)
290
+ server_address = f"{network.network_address + 1}/{network.prefixlen}"
291
+
292
+ return dedent(f"""\
293
+ #!/bin/bash
294
+ set -euo pipefail
295
+ export PATH=/usr/sbin:/usr/bin:/sbin:/bin
296
+ case "${{1:-}}" in
297
+ up)
298
+ for attempt in {{1..50}}; do
299
+ if [ -S /run/amneziawg/{interface}.sock ]; then break; fi
300
+ sleep 0.1
301
+ done
302
+ [ -S /run/amneziawg/{interface}.sock ]
303
+ {base}/bin/awg setconf {interface} {base}/server.conf
304
+ ip address replace {server_address} dev {interface}
305
+ ip link set dev {interface} mtu {settings.mtu} up
306
+ sysctl -q -w net.ipv4.ip_forward=1
307
+ if nft list table ip {table} >/dev/null 2>&1; then
308
+ {{ printf 'delete table ip {table}\\n'; cat {base}/firewall.nft; }} | nft -f -
309
+ else
310
+ nft -f {base}/firewall.nft
311
+ fi
312
+ ;;
313
+ down)
314
+ if nft list table ip {table} >/dev/null 2>&1; then nft delete table ip {table}; fi
315
+ ;;
316
+ *) exit 2 ;;
317
+ esac
318
+ """)
319
+
320
+
321
+ def _Service(service: str, base: str, interface: str) -> str:
322
+ """Keep tunnel startup and firewall recovery persistent across guest reboots."""
323
+
324
+ return dedent(f"""\
325
+ # Owned by {service}
326
+ [Unit]
327
+ Description=F-Layer AmneziaWG tunnel
328
+ Wants=network-online.target
329
+ After=network-online.target nftables.service
330
+ [Service]
331
+ Type=simple
332
+ UMask=0077
333
+ Environment=LOG_LEVEL=error
334
+ ExecStart={base}/bin/amneziawg-go -f {interface}
335
+ ExecStartPost={base}/network.sh up
336
+ ExecStopPost={base}/network.sh down
337
+ Restart=on-failure
338
+ RestartSec=5
339
+ TimeoutStartSec=30
340
+ [Install]
341
+ WantedBy=multi-user.target
342
+ """)
343
+
344
+
345
+ def _Installer(service: str, base: str, interface: str) -> str:
346
+ """Render the bounded root installer, ownership guard, health probe, and removal path."""
347
+
348
+ template = r'''#!/bin/bash
349
+ set -euo pipefail
350
+ umask 077
351
+ export PATH=/usr/sbin:/usr/bin:/sbin:/bin
352
+ export DEBIAN_FRONTEND=noninteractive
353
+ base='@@BASE@@'
354
+ service='@@SERVICE@@'
355
+ unit="/etc/systemd/system/${service}.service"
356
+ [ "$(id -u)" -eq 0 ] || { echo 'Root privileges are required' >&2; exit 1; }
357
+ [ "$#" -le 1 ] || exit 2
358
+ [ ! -L /etc/flayer ]
359
+ exec 9>"/run/lock/${service}.lock"
360
+ flock -x 9
361
+ if [ -e "$base" ] || [ -L "$base" ]; then
362
+ [ ! -L "$base" ] && [ -d "$base" ]
363
+ [ -f "$base/owner" ] && [ ! -L "$base/owner" ]
364
+ [ "$(cat "$base/owner")" = "$service" ]
365
+ [ -z "$(find "$base" -type l -print -quit)" ]
366
+ [ -z "$(find "$base" ! -user root -print -quit)" ]
367
+ [ -z "$(find "$base" -type f -links +1 -print -quit)" ]
368
+ fi
369
+ if [ -e "$unit" ] || [ -L "$unit" ]; then
370
+ [ ! -L "$unit" ] && [ -f "$unit" ]
371
+ grep -qxF "# Owned by $service" "$unit"
372
+ fi
373
+ Check() {
374
+ systemctl is-active --quiet "${service}.service"
375
+ [ "$("$base/bin/awg" show @@INTERFACE@@ listen-port)" -gt 0 ]
376
+ ip -4 address show dev @@INTERFACE@@ | grep -q 'inet '
377
+ [ "$(sysctl -n net.ipv4.ip_forward)" = 1 ]
378
+ nft list table ip @@TABLE@@ >/dev/null
379
+ }
380
+ case "${1:-}" in
381
+ --check) Check; exit 0 ;;
382
+ --remove)
383
+ [ -d "$base" ] || exit 0
384
+ systemctl disable --now "${service}.service"
385
+ rm -f -- "$unit"
386
+ rm -rf -- "$base"
387
+ systemctl daemon-reload
388
+ exit 0
389
+ ;;
390
+ '') ;;
391
+ *) echo 'Usage: install.sh [--check|--remove]' >&2; exit 2 ;;
392
+ esac
393
+ . /etc/os-release
394
+ [ "$ID" = ubuntu ] && [ "$VERSION_ID" = 24.04 ]
395
+ [ "$(uname -m)" = x86_64 ]
396
+ [ -c /dev/net/tun ]
397
+ source_dir="$(cd -- "$(dirname -- "$0")" && pwd -P)"
398
+ for name in install.sh server.conf firewall.nft network.sh service.service; do
399
+ [ -f "$source_dir/$name" ] && [ ! -L "$source_dir/$name" ]
400
+ [ "$(stat -c '%u:%a:%h' "$source_dir/$name")" = '0:600:1' ]
401
+ done
402
+ payload_hash="$(cd "$source_dir" && sha256sum install.sh server.conf firewall.nft network.sh service.service | sha256sum | cut -d ' ' -f 1)"
403
+ if [ -d "$base" ]; then
404
+ if [ ! -f "$base/payload-hash" ] || [ "$(cat "$base/payload-hash")" != "$payload_hash" ]; then
405
+ echo 'Replacement requires an explicit expected deployment receipt' >&2
406
+ exit 1
407
+ fi
408
+ if Check; then exit 0; fi
409
+ fi
410
+ apt-get -q update
411
+ apt-get -q install --no-install-recommends -y ca-certificates curl git build-essential nftables iproute2
412
+ work="$(mktemp -d /var/tmp/flayer-awg.XXXXXXXX)"
413
+ trap 'rm -rf -- "$work"' EXIT
414
+ curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 \
415
+ --connect-timeout 20 --max-time 300 'https://go.dev/dl/go@@GO_VERSION@@.linux-amd64.tar.gz' \
416
+ --output "$work/go.tar.gz"
417
+ printf '%s %s\n' '@@GO_HASH@@' "$work/go.tar.gz" | sha256sum --check --status
418
+ mkdir "$work/toolchain"
419
+ tar -xzf "$work/go.tar.gz" -C "$work/toolchain"
420
+ export GOTOOLCHAIN=local GOSUMDB=sum.golang.org GOPROXY=https://proxy.golang.org
421
+ export GOCACHE="$work/go-cache" GOPATH="$work/go-path" CGO_ENABLED=0
422
+ for component in go tools; do
423
+ git init --quiet "$work/$component"
424
+ git -C "$work/$component" remote add origin "https://github.com/amnezia-vpn/amneziawg-$component.git"
425
+ if [ "$component" = go ]; then revision='@@GO_REV@@'; else revision='@@TOOLS_REV@@'; fi
426
+ git -C "$work/$component" -c advice.detachedHead=false fetch --quiet --depth=1 origin "$revision"
427
+ [ "$(git -C "$work/$component" rev-parse FETCH_HEAD)" = "$revision" ]
428
+ git -C "$work/$component" -c advice.detachedHead=false checkout --quiet --detach FETCH_HEAD
429
+ done
430
+ printf 'package main\n\nconst Version = "@@GO_RELEASE@@"\n' > "$work/go/version.go"
431
+ (cd "$work/go" && "$work/toolchain/go/bin/go" build -mod=readonly -trimpath -buildvcs=false -o "$work/amneziawg-go" .)
432
+ make -s -C "$work/tools/src" wg
433
+ nft --check --file "$source_dir/firewall.nft"
434
+ had_previous=0
435
+ if [ -d "$base" ]; then
436
+ cp -a -- "$base" "$work/previous"
437
+ cp -- "$unit" "$work/previous.service"
438
+ had_previous=1
439
+ systemctl stop "${service}.service"
440
+ fi
441
+ Rollback() {
442
+ trap - ERR
443
+ systemctl stop "${service}.service" >/dev/null 2>&1 || true
444
+ if [ "$had_previous" -eq 1 ]; then
445
+ rm -rf -- "$base"
446
+ cp -a -- "$work/previous" "$base"
447
+ install -m 0644 "$work/previous.service" "$unit"
448
+ systemctl daemon-reload
449
+ systemctl start "${service}.service" >/dev/null 2>&1 || true
450
+ else
451
+ systemctl disable "${service}.service" >/dev/null 2>&1 || true
452
+ rm -f -- "$unit"
453
+ rm -rf -- "$base"
454
+ systemctl daemon-reload
455
+ fi
456
+ echo 'AmneziaWG installation failed; inspect the guest service locally' >&2
457
+ exit 1
458
+ }
459
+ trap Rollback ERR
460
+ install -d -m 0700 "$base" "$base/bin"
461
+ printf '%s\n' "$service" > "$base/owner"
462
+ printf '%s\n' "$payload_hash" > "$base/payload-hash"
463
+ install -m 0700 "$work/amneziawg-go" "$base/bin/amneziawg-go"
464
+ install -m 0700 "$work/tools/src/wg" "$base/bin/awg"
465
+ install -m 0600 "$source_dir/server.conf" "$base/server.conf"
466
+ install -m 0600 "$source_dir/firewall.nft" "$base/firewall.nft"
467
+ install -m 0700 "$source_dir/network.sh" "$base/network.sh"
468
+ install -m 0644 "$source_dir/service.service" "$unit"
469
+ systemctl daemon-reload
470
+ systemctl enable --now "${service}.service"
471
+ Check
472
+ trap - ERR
473
+ '''
474
+ replacements = {
475
+ "BASE": base, "SERVICE": service, "INTERFACE": interface,
476
+ "TABLE": interface, "GO_VERSION": GO_VERSION, "GO_HASH": GO_LINUX_AMD64_SHA256,
477
+ "GO_REV": AMNEZIAWG_GO_REVISION, "TOOLS_REV": AMNEZIAWG_TOOLS_REVISION,
478
+ "GO_RELEASE": AMNEZIAWG_GO_VERSION,
479
+ }
480
+
481
+ for name, value in replacements.items():
482
+ template = template.replace(f"@@{name}@@", value)
483
+
484
+ return template
485
+
486
+
487
+ def PrepareAmneziaWg(
488
+ profile: VpnProfile, endpoint: VpnEndpoint, settings: AmneziaWgSettings,
489
+ secret_values: AmneziaWgSecrets,
490
+ ) -> PreparedVpnTransport:
491
+ """Produce deterministic private server/client artifacts without any external operation."""
492
+
493
+ listener = ValidateVpnTransport(profile, AMNEZIAWG_CAPABILITIES)
494
+
495
+ if (
496
+ not isinstance(endpoint, VpnEndpoint) or endpoint.port != listener.port
497
+ or listener.protocol != "udp" or not isinstance(settings, AmneziaWgSettings)
498
+ or not isinstance(secret_values, AmneziaWgSecrets)
499
+ ):
500
+ raise VpnError("AmneziaWG requires matching UDP endpoint, settings, and private keys")
501
+
502
+ device_keys = {item.device_id: item.private_key for item in secret_values.device_private_keys}
503
+
504
+ if set(device_keys) != {item.device_id for item in profile.devices}:
505
+ raise VpnError("AmneziaWG secrets must match exactly the declared devices")
506
+
507
+ network = ipaddress.IPv4Network(settings.tunnel_cidr)
508
+
509
+ for device in profile.devices:
510
+ address = ipaddress.IPv4Address(str(device.ipv4_address))
511
+
512
+ if address not in network or address in {
513
+ network.network_address, network.network_address + 1, network.broadcast_address,
514
+ }:
515
+ raise VpnError("AmneziaWG device addresses must be usable nonserver tunnel hosts")
516
+
517
+ public_keys = {key: _PublicKey(value) for key, value in device_keys.items()}
518
+ server_public_key = _PublicKey(secret_values.server_private_key)
519
+
520
+ if len(set(public_keys.values()) | {server_public_key}) != len(public_keys) + 1:
521
+ raise VpnError("AmneziaWG peers must have distinct Curve25519 identities")
522
+
523
+ parameters = _Parameters(secret_values)
524
+ server_lines = ["[Interface]", f"PrivateKey = {secret_values.server_private_key}",
525
+ f"ListenPort = {listener.port}", *parameters]
526
+
527
+ for device in profile.devices:
528
+ server_lines.extend(["", "[Peer]", f"PublicKey = {public_keys[device.device_id]}",
529
+ f"AllowedIPs = {device.ipv4_address}/32"])
530
+
531
+ service = AmneziaWgServiceName(profile)
532
+ interface = "fa" + service.removeprefix("flayer-awg-")
533
+ base = "/etc/flayer/" + service
534
+ server_bundle = BuildVpnServerBundle(profile, "amneziawg", (
535
+ Artifact("server.conf", ("\n".join(server_lines) + "\n").encode()),
536
+ Artifact("install.sh", _Installer(service, base, interface).encode()),
537
+ Artifact("firewall.nft", _Firewall(profile, interface, interface).encode()),
538
+ Artifact("network.sh", _NetworkScript(base, interface, interface, settings).encode()),
539
+ Artifact("service.service", _Service(service, base, interface).encode()),
540
+ ))
541
+ device_bundles = []
542
+
543
+ for device in profile.devices:
544
+ client_lines = [
545
+ "[Interface]", f"PrivateKey = {device_keys[device.device_id]}",
546
+ f"Address = {device.ipv4_address}/32", f"MTU = {settings.mtu}",
547
+ "DNS = " + ", ".join(profile.routes.dns_servers), *parameters, "", "[Peer]",
548
+ f"PublicKey = {server_public_key}", f"Endpoint = {endpoint.host}:{endpoint.port}",
549
+ "AllowedIPs = " + ", ".join(profile.routes.ipv4_cidrs), "PersistentKeepalive = 25", "",
550
+ ]
551
+ device_bundles.append(BuildVpnDeviceBundle(profile, "amneziawg", device.device_id, (
552
+ Artifact("amneziawg.conf", "\n".join(client_lines).encode()),
553
+ )))
554
+
555
+ return PreparedVpnTransport(profile, AMNEZIAWG_CAPABILITIES, server_bundle, tuple(device_bundles))