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,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))
|