nullforge 0.1.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.
- nullforge/__init__.py +1 -0
- nullforge/foundry/README.md +0 -0
- nullforge/foundry/__init__.py +7 -0
- nullforge/foundry/full_cast.py +22 -0
- nullforge/inventories/README.md +0 -0
- nullforge/inventories/example.py +87 -0
- nullforge/models/__init__.py +1 -0
- nullforge/models/containers.py +60 -0
- nullforge/models/dns.py +222 -0
- nullforge/models/monitoring/__init__.py +29 -0
- nullforge/models/monitoring/base.py +19 -0
- nullforge/models/monitoring/nezha.py +13 -0
- nullforge/models/netsec.py +43 -0
- nullforge/models/profiles.py +33 -0
- nullforge/models/system.py +15 -0
- nullforge/models/users.py +9 -0
- nullforge/models/warp.py +65 -0
- nullforge/models/zerotrust.py +9 -0
- nullforge/molds/__init__.py +38 -0
- nullforge/molds/base_mold.py +59 -0
- nullforge/molds/containers.py +26 -0
- nullforge/molds/defaults.py +10 -0
- nullforge/molds/dns.py +70 -0
- nullforge/molds/features.py +75 -0
- nullforge/molds/haproxy.py +23 -0
- nullforge/molds/monitoring/__init__.py +45 -0
- nullforge/molds/monitoring/nezha.py +118 -0
- nullforge/molds/netsec.py +237 -0
- nullforge/molds/profiles.py +30 -0
- nullforge/molds/system.py +140 -0
- nullforge/molds/telemt.py +140 -0
- nullforge/molds/tor.py +28 -0
- nullforge/molds/user.py +77 -0
- nullforge/molds/utils.py +115 -0
- nullforge/molds/warp.py +54 -0
- nullforge/molds/xray.py +16 -0
- nullforge/molds/zerotrust.py +59 -0
- nullforge/runes/__init__.py +46 -0
- nullforge/runes/base.py +327 -0
- nullforge/runes/containers.py +212 -0
- nullforge/runes/dns.py +256 -0
- nullforge/runes/haproxy.py +135 -0
- nullforge/runes/monitoring.py +21 -0
- nullforge/runes/netsec.py +547 -0
- nullforge/runes/prepare.py +30 -0
- nullforge/runes/profiles.py +583 -0
- nullforge/runes/telemt.py +252 -0
- nullforge/runes/tor.py +57 -0
- nullforge/runes/users.py +159 -0
- nullforge/runes/warp.py +273 -0
- nullforge/runes/xray.py +81 -0
- nullforge/runes/zerotrust.py +91 -0
- nullforge/smithy/__init__.py +1 -0
- nullforge/smithy/admin.py +40 -0
- nullforge/smithy/arch.py +36 -0
- nullforge/smithy/blocky.py +37 -0
- nullforge/smithy/cloudflare.py +32 -0
- nullforge/smithy/github.py +256 -0
- nullforge/smithy/http.py +85 -0
- nullforge/smithy/install.py +149 -0
- nullforge/smithy/monitoring/__init__.py +1 -0
- nullforge/smithy/monitoring/nezha/__init__.py +0 -0
- nullforge/smithy/monitoring/nezha/agent.py +54 -0
- nullforge/smithy/monitoring/nezha/dashboard.py +137 -0
- nullforge/smithy/monitoring/nezha/deploy.py +81 -0
- nullforge/smithy/network.py +95 -0
- nullforge/smithy/packages.py +176 -0
- nullforge/smithy/service.py +37 -0
- nullforge/smithy/sni.py +358 -0
- nullforge/smithy/swap.py +132 -0
- nullforge/smithy/system.py +86 -0
- nullforge/smithy/versions.py +282 -0
- nullforge/templates/__init__.py +32 -0
- nullforge/templates/cloudflared/tunnel.yml.j2 +6 -0
- nullforge/templates/dns/blocky.yaml.j2 +11 -0
- nullforge/templates/dns/dns.yaml.j2 +6 -0
- nullforge/templates/dns/resolv.conf.j2 +1 -0
- nullforge/templates/dns/resolved.conf.j2 +13 -0
- nullforge/templates/etc/default/zramswap.j2 +13 -0
- nullforge/templates/nvim/nvim_patch.lua.j2 +5 -0
- nullforge/templates/profiles/direnv.toml +2 -0
- nullforge/templates/profiles/starship.toml +186 -0
- nullforge/templates/profiles/tmux.conf +32 -0
- nullforge/templates/profiles/zshrc.j2 +46 -0
- nullforge/templates/scripts/telemt-synfix.sh +89 -0
- nullforge/templates/scripts/teleproxy-warp.sh +127 -0
- nullforge/templates/scripts/warp-v6-policy.sh +118 -0
- nullforge/templates/scripts/zt-tunnel-warp.sh +28 -0
- nullforge/templates/systemd/blocky.service.j2 +19 -0
- nullforge/templates/systemd/cloudflare-tunnel.service.j2 +27 -0
- nullforge/templates/systemd/cloudflare-warp.service.j2 +21 -0
- nullforge/templates/systemd/dns-internal.service.j2 +15 -0
- nullforge/templates/systemd/telemt.service.j2 +36 -0
- nullforge/templates/telemt/telemt.toml.j2 +49 -0
- nullforge/templates/tor/torrc.j2 +5 -0
- nullforge-0.1.0.dist-info/METADATA +39 -0
- nullforge-0.1.0.dist-info/RECORD +99 -0
- nullforge-0.1.0.dist-info/WHEEL +4 -0
- nullforge-0.1.0.dist-info/licenses/LICENSE +7 -0
nullforge/smithy/sni.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""TLS key-exchange probing for SNI masking domains.
|
|
2
|
+
|
|
3
|
+
Anything that hides behind someone else's domain - Fake-TLS, REALITY - is only as
|
|
4
|
+
convincing as the domain it borrows, and what matters is what a real client actually
|
|
5
|
+
negotiates with it: whether a given key-exchange group is on offer at all, and which
|
|
6
|
+
group is used when it is not.
|
|
7
|
+
|
|
8
|
+
The probe speaks TLS directly rather than shelling out to ``openssl s_client -groups
|
|
9
|
+
...``, which for post-quantum groups would require OpenSSL 3.5+ on the control node.
|
|
10
|
+
Offering a group and reading back the server's choice needs no implementation of that
|
|
11
|
+
group's math on our side, so this works on any control node and is testable offline.
|
|
12
|
+
No handshake is ever completed: the server names its choice in its first flight, so the
|
|
13
|
+
probe reads that and drops the connection.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TLS_PORT = 443
|
|
25
|
+
"""Port a masking domain is probed on; these protocols only ever impersonate HTTPS."""
|
|
26
|
+
|
|
27
|
+
PROBE_TIMEOUT = 10
|
|
28
|
+
"""Seconds to wait for connect and for the server's first flight."""
|
|
29
|
+
|
|
30
|
+
GROUP_X25519 = 0x001D
|
|
31
|
+
GROUP_SECP256R1 = 0x0017
|
|
32
|
+
GROUP_SECP384R1 = 0x0018
|
|
33
|
+
GROUP_X448 = 0x001E
|
|
34
|
+
GROUP_SECP521R1 = 0x0019
|
|
35
|
+
GROUP_X25519MLKEM768 = 0x11EC
|
|
36
|
+
|
|
37
|
+
GROUP_NAMES = {
|
|
38
|
+
GROUP_X25519: "X25519",
|
|
39
|
+
GROUP_SECP256R1: "secp256r1",
|
|
40
|
+
GROUP_SECP384R1: "secp384r1",
|
|
41
|
+
GROUP_X448: "x448",
|
|
42
|
+
GROUP_SECP521R1: "secp521r1",
|
|
43
|
+
GROUP_X25519MLKEM768: "X25519MLKEM768",
|
|
44
|
+
}
|
|
45
|
+
"""IANA TLS supported-group names, for rendering a report."""
|
|
46
|
+
|
|
47
|
+
CLASSICAL_GROUPS = (GROUP_X25519, GROUP_SECP256R1, GROUP_SECP384R1, GROUP_X448, GROUP_SECP521R1)
|
|
48
|
+
"""What a client with no post-quantum support offers, in the usual browser order."""
|
|
49
|
+
|
|
50
|
+
_KEY_EXCHANGE_SIZES = {GROUP_X25519: 32}
|
|
51
|
+
"""Public-key sizes for groups we can guess a key_share for."""
|
|
52
|
+
|
|
53
|
+
_CIPHER_SUITES = (0x1301, 0x1302, 0x1303)
|
|
54
|
+
"""TLS 1.3 suites; a server must accept one of these to be worth probing."""
|
|
55
|
+
|
|
56
|
+
_SIG_ALGS = (0x0403, 0x0503, 0x0603, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601)
|
|
57
|
+
"""Servers pick a certificate from these, so omitting them draws spurious alerts."""
|
|
58
|
+
|
|
59
|
+
_EXT_SERVER_NAME = 0x0000
|
|
60
|
+
_EXT_SUPPORTED_GROUPS = 0x000A
|
|
61
|
+
_EXT_SIG_ALGS = 0x000D
|
|
62
|
+
_EXT_SUPPORTED_VERSIONS = 0x002B
|
|
63
|
+
_EXT_KEY_SHARE = 0x0033
|
|
64
|
+
|
|
65
|
+
_REC_ALERT = 0x15
|
|
66
|
+
_REC_HANDSHAKE = 0x16
|
|
67
|
+
_HS_SERVER_HELLO = 0x02
|
|
68
|
+
|
|
69
|
+
_ALERT_HANDSHAKE_FAILURE = 40
|
|
70
|
+
_ALERT_PROTOCOL_VERSION = 70
|
|
71
|
+
_ALERT_INSUFFICIENT_SECURITY = 71
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def group_name(group: int | None) -> str:
|
|
75
|
+
"""Render a group code for humans, falling back to its hex code."""
|
|
76
|
+
|
|
77
|
+
if group is None:
|
|
78
|
+
return "none"
|
|
79
|
+
return GROUP_NAMES.get(group, f"0x{group:04x}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class KeyExchangeReport:
|
|
84
|
+
"""What `domain` negotiates for the probed `group`, and for a client without it.
|
|
85
|
+
|
|
86
|
+
When `error` is set the probe reached no conclusion and no other field carries
|
|
87
|
+
information.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
domain: str
|
|
91
|
+
group: int
|
|
92
|
+
error: str | None = None
|
|
93
|
+
supported: bool = False
|
|
94
|
+
"""Whether `domain` negotiates `group`."""
|
|
95
|
+
|
|
96
|
+
fallback: int | None = None
|
|
97
|
+
"""Group a client that cannot offer `group` negotiates instead.
|
|
98
|
+
|
|
99
|
+
None when `supported` is true (no reason to ask), or when the domain turned out to
|
|
100
|
+
negotiate nothing at all.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
supports_tls13: bool = True
|
|
104
|
+
"""False only when the peer refuses TLS 1.3 outright, which rules out any TLS 1.3 group."""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def conclusive(self) -> bool:
|
|
108
|
+
"""Whether the probe learned anything; check this before reading other fields."""
|
|
109
|
+
|
|
110
|
+
return self.error is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SniProbeError(Exception):
|
|
114
|
+
"""The peer could not be probed, or answered with something that is not TLS."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class NoTlsThirteen(SniProbeError):
|
|
118
|
+
"""The peer refuses TLS 1.3, so no TLS 1.3 key-exchange group is reachable on it."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_cache: dict[tuple[str, int, int], KeyExchangeReport] = {}
|
|
122
|
+
_cache_lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def inspect_group(
|
|
126
|
+
domain: str,
|
|
127
|
+
group: int,
|
|
128
|
+
*,
|
|
129
|
+
port: int = TLS_PORT,
|
|
130
|
+
timeout: int = PROBE_TIMEOUT,
|
|
131
|
+
) -> KeyExchangeReport:
|
|
132
|
+
"""Report whether `domain` negotiates `group`, and what it falls back to if not.
|
|
133
|
+
|
|
134
|
+
Never raises: a probe that reaches no conclusion comes back with `error` set, so
|
|
135
|
+
callers vetting a domain cannot be broken by a network blip. Results are cached per
|
|
136
|
+
(domain, port, group) so a domain shared by several hosts is probed once per run.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
key = (domain, port, group)
|
|
140
|
+
with _cache_lock:
|
|
141
|
+
cached = _cache.get(key)
|
|
142
|
+
if cached is not None:
|
|
143
|
+
return cached
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
report = _inspect(domain, group, port, timeout)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
report = KeyExchangeReport(domain=domain, group=group, error=f"probe failed: {e!r}")
|
|
149
|
+
|
|
150
|
+
with _cache_lock:
|
|
151
|
+
_cache.setdefault(key, report)
|
|
152
|
+
return report
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _inspect(domain: str, group: int, port: int, timeout: int) -> KeyExchangeReport:
|
|
156
|
+
"""Probe for `group`, then probe the classical fallback only if `group` is refused."""
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
selected = _negotiated_group(domain, port, (group,), timeout)
|
|
160
|
+
except NoTlsThirteen:
|
|
161
|
+
# Conclusive rather than inconclusive: no TLS 1.3 means no TLS 1.3 group.
|
|
162
|
+
return KeyExchangeReport(domain=domain, group=group, supports_tls13=False, supported=False)
|
|
163
|
+
except SniProbeError as e:
|
|
164
|
+
return KeyExchangeReport(domain=domain, group=group, error=str(e))
|
|
165
|
+
|
|
166
|
+
if selected == group:
|
|
167
|
+
return KeyExchangeReport(domain=domain, group=group, supported=True)
|
|
168
|
+
|
|
169
|
+
# The server refused the only group we offered. Ask again as a real client would, to
|
|
170
|
+
# learn what it actually falls back to. This probe *does* guess a key_share, because
|
|
171
|
+
# servers accept a usable guess rather than pay for a retry - an empty key_share
|
|
172
|
+
# would report the server's own preference instead, which is not what a client gets.
|
|
173
|
+
offer = tuple(g for g in CLASSICAL_GROUPS if g != group)
|
|
174
|
+
try:
|
|
175
|
+
fallback = _negotiated_group(domain, port, offer, timeout, key_share_group=GROUP_X25519)
|
|
176
|
+
except SniProbeError as e:
|
|
177
|
+
detail = f"{group_name(group)} refused, fallback probe failed: {e}"
|
|
178
|
+
return KeyExchangeReport(domain=domain, group=group, error=detail)
|
|
179
|
+
|
|
180
|
+
return KeyExchangeReport(domain=domain, group=group, supported=False, fallback=fallback)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _negotiated_group(
|
|
184
|
+
domain: str,
|
|
185
|
+
port: int,
|
|
186
|
+
groups: Sequence[int],
|
|
187
|
+
timeout: int,
|
|
188
|
+
*,
|
|
189
|
+
key_share_group: int | None = None,
|
|
190
|
+
) -> int | None:
|
|
191
|
+
"""Return the group the server picks from `groups`, or None if it refuses them all.
|
|
192
|
+
|
|
193
|
+
Raises SniProbeError when the peer cannot be reached or does not speak TLS 1.3.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
hello = _client_hello(domain, groups, key_share_group=key_share_group)
|
|
197
|
+
try:
|
|
198
|
+
with socket.create_connection((domain, port), timeout=timeout) as sock:
|
|
199
|
+
sock.settimeout(timeout)
|
|
200
|
+
sock.sendall(hello)
|
|
201
|
+
return _read_selected_group(sock)
|
|
202
|
+
except SniProbeError:
|
|
203
|
+
raise
|
|
204
|
+
except OSError as e:
|
|
205
|
+
raise SniProbeError(f"connection to {domain}:{port} failed: {e}") from e
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read_selected_group(sock: socket.socket) -> int | None:
|
|
209
|
+
"""Parse the server's first record and return its chosen group.
|
|
210
|
+
|
|
211
|
+
The group is named in the key_share of either a HelloRetryRequest (when we sent no
|
|
212
|
+
guess, RFC 8446 4.1.4) or a plain ServerHello (when the server took our guess), so
|
|
213
|
+
it is readable without completing - or even being able to complete - a handshake.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
header = _read_exactly(sock, 5)
|
|
217
|
+
rec_type, _, length = struct.unpack(">BHH", header)
|
|
218
|
+
payload = _read_exactly(sock, length)
|
|
219
|
+
|
|
220
|
+
if rec_type == _REC_ALERT:
|
|
221
|
+
desc = payload[1] if len(payload) > 1 else 0
|
|
222
|
+
if desc in (_ALERT_HANDSHAKE_FAILURE, _ALERT_INSUFFICIENT_SECURITY):
|
|
223
|
+
return None # "none of the groups you offered"
|
|
224
|
+
if desc == _ALERT_PROTOCOL_VERSION:
|
|
225
|
+
raise NoTlsThirteen("server rejected TLS 1.3 (alert protocol_version)")
|
|
226
|
+
raise SniProbeError(f"server sent TLS alert {desc}")
|
|
227
|
+
if rec_type != _REC_HANDSHAKE:
|
|
228
|
+
raise SniProbeError(f"expected a handshake record, got type 0x{rec_type:02x}")
|
|
229
|
+
if not payload or payload[0] != _HS_SERVER_HELLO:
|
|
230
|
+
raise SniProbeError("server did not answer with a ServerHello")
|
|
231
|
+
|
|
232
|
+
body = payload[4 : 4 + int.from_bytes(payload[1:4], "big")]
|
|
233
|
+
return _selected_group_from_server_hello(body)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _selected_group_from_server_hello(body: bytes) -> int | None:
|
|
237
|
+
"""Pull key_share's group out of a ServerHello / HelloRetryRequest body."""
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
pos = 2 # legacy_version
|
|
241
|
+
pos += 32 # random; HRR-vs-ServerHello does not change where the group is read from
|
|
242
|
+
pos += 1 + body[pos] # legacy_session_id_echo
|
|
243
|
+
pos += 2 # cipher_suite
|
|
244
|
+
pos += 1 # legacy_compression_method
|
|
245
|
+
ext_len = int.from_bytes(body[pos : pos + 2], "big")
|
|
246
|
+
pos += 2
|
|
247
|
+
extensions = body[pos : pos + ext_len]
|
|
248
|
+
except IndexError as e:
|
|
249
|
+
raise SniProbeError("malformed ServerHello") from e
|
|
250
|
+
|
|
251
|
+
parsed = _parse_extensions(extensions)
|
|
252
|
+
|
|
253
|
+
if parsed.get(_EXT_SUPPORTED_VERSIONS) != b"\x03\x04":
|
|
254
|
+
# A TLS 1.2 ServerHello: same conclusion as alert 70, just stated politely.
|
|
255
|
+
raise NoTlsThirteen("server negotiated a pre-TLS 1.3 version")
|
|
256
|
+
|
|
257
|
+
key_share = parsed.get(_EXT_KEY_SHARE)
|
|
258
|
+
if key_share is None or len(key_share) < 2:
|
|
259
|
+
# A real ServerHello without key_share means PSK-only; a HelloRetryRequest
|
|
260
|
+
# always carries one, so either way we learned nothing about groups.
|
|
261
|
+
raise SniProbeError("ServerHello carried no key_share")
|
|
262
|
+
|
|
263
|
+
return int.from_bytes(key_share[:2], "big")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_extensions(raw: bytes) -> dict[int, bytes]:
|
|
267
|
+
"""Walk a TLS extension block into {type: body}."""
|
|
268
|
+
|
|
269
|
+
extensions: dict[int, bytes] = {}
|
|
270
|
+
pos = 0
|
|
271
|
+
while pos + 4 <= len(raw):
|
|
272
|
+
kind, size = struct.unpack(">HH", raw[pos : pos + 4])
|
|
273
|
+
pos += 4
|
|
274
|
+
if pos + size > len(raw):
|
|
275
|
+
raise SniProbeError("truncated TLS extension")
|
|
276
|
+
extensions[kind] = raw[pos : pos + size]
|
|
277
|
+
pos += size
|
|
278
|
+
return extensions
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _read_exactly(sock: socket.socket, count: int) -> bytes:
|
|
282
|
+
"""Read exactly `count` bytes, or raise SniProbeError."""
|
|
283
|
+
|
|
284
|
+
chunks: list[bytes] = []
|
|
285
|
+
remaining = count
|
|
286
|
+
while remaining > 0:
|
|
287
|
+
try:
|
|
288
|
+
chunk = sock.recv(remaining)
|
|
289
|
+
except OSError as e:
|
|
290
|
+
raise SniProbeError(f"read failed: {e}") from e
|
|
291
|
+
if not chunk:
|
|
292
|
+
raise SniProbeError("peer closed the connection mid-response")
|
|
293
|
+
chunks.append(chunk)
|
|
294
|
+
remaining -= len(chunk)
|
|
295
|
+
return b"".join(chunks)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _client_hello(domain: str, groups: Sequence[int], *, key_share_group: int | None = None) -> bytes:
|
|
299
|
+
"""Build a TLS 1.3 ClientHello offering `groups`, optionally guessing a key_share.
|
|
300
|
+
|
|
301
|
+
With no `key_share_group` the client_shares vector is left empty, which makes the
|
|
302
|
+
server name its own choice in a HelloRetryRequest - the only way to ask about a
|
|
303
|
+
group whose share we cannot generate, such as X25519MLKEM768.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
host = domain.encode("idna")
|
|
307
|
+
sni = b"\x00" + struct.pack(">H", len(host)) + host
|
|
308
|
+
|
|
309
|
+
extensions = b"".join(
|
|
310
|
+
(
|
|
311
|
+
_extension(_EXT_SERVER_NAME, struct.pack(">H", len(sni)) + sni),
|
|
312
|
+
_extension(_EXT_SUPPORTED_GROUPS, _u16_vector(groups)),
|
|
313
|
+
_extension(_EXT_SIG_ALGS, _u16_vector(_SIG_ALGS)),
|
|
314
|
+
_extension(_EXT_SUPPORTED_VERSIONS, b"\x02\x03\x04"),
|
|
315
|
+
_extension(_EXT_KEY_SHARE, _client_shares(key_share_group)),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
body = b"".join(
|
|
320
|
+
(
|
|
321
|
+
b"\x03\x03",
|
|
322
|
+
os.urandom(32),
|
|
323
|
+
b"\x20" + os.urandom(32), # legacy_session_id, non-empty for middlebox compat
|
|
324
|
+
_u16_vector(_CIPHER_SUITES),
|
|
325
|
+
b"\x01\x00", # legacy_compression_methods: null
|
|
326
|
+
struct.pack(">H", len(extensions)) + extensions,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
handshake = b"\x01" + len(body).to_bytes(3, "big") + body
|
|
331
|
+
return b"\x16\x03\x01" + struct.pack(">H", len(handshake)) + handshake
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _client_shares(group: int | None) -> bytes:
|
|
335
|
+
"""Encode the key_share body: an empty vector, or one junk share for `group`.
|
|
336
|
+
|
|
337
|
+
The share is random rather than a real keypair. Any 32 bytes is a well-formed X25519
|
|
338
|
+
public key, so the server completes its side and reveals the negotiated group in the
|
|
339
|
+
ServerHello; we never read far enough for the bogus shared secret to matter, and the
|
|
340
|
+
connection is dropped immediately afterwards.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if group is None:
|
|
344
|
+
return b"\x00\x00"
|
|
345
|
+
|
|
346
|
+
size = _KEY_EXCHANGE_SIZES[group]
|
|
347
|
+
entry = struct.pack(">HH", group, size) + os.urandom(size)
|
|
348
|
+
return struct.pack(">H", len(entry)) + entry
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _extension(kind: int, body: bytes) -> bytes:
|
|
352
|
+
return struct.pack(">HH", kind, len(body)) + body
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _u16_vector(values: Sequence[int]) -> bytes:
|
|
356
|
+
"""Encode `values` as a TLS vector of u16s prefixed with its byte length."""
|
|
357
|
+
|
|
358
|
+
return struct.pack(">H", 2 * len(values)) + b"".join(struct.pack(">H", v) for v in values)
|
nullforge/smithy/swap.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Swap configuration module."""
|
|
2
|
+
|
|
3
|
+
from pyinfra.context import host
|
|
4
|
+
from pyinfra.facts.files import File
|
|
5
|
+
from pyinfra.operations import files, server, systemd
|
|
6
|
+
|
|
7
|
+
from nullforge.models.system import SwapAlgo, SwapType
|
|
8
|
+
from nullforge.molds import SystemMold
|
|
9
|
+
from nullforge.smithy.packages import get_pm
|
|
10
|
+
from nullforge.templates import BLOCK_TRIM_ENV, get_template_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_swap(system: SystemMold) -> None:
|
|
14
|
+
if not system.swap.enabled:
|
|
15
|
+
_disable_swap()
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
_set_swappiness(system.swap.swappiness)
|
|
19
|
+
|
|
20
|
+
if system.swap.type == SwapType.ZRAM:
|
|
21
|
+
_disable_basic_swap()
|
|
22
|
+
_configure_zram(system.swap.size, system.swap.algo)
|
|
23
|
+
else:
|
|
24
|
+
_disable_zram()
|
|
25
|
+
_configure_basic_swap(system.swap.size)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _disable_swap() -> None:
|
|
29
|
+
_disable_basic_swap()
|
|
30
|
+
_disable_zram()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _disable_basic_swap() -> None:
|
|
34
|
+
if host.get_fact(File, "/swapfile"):
|
|
35
|
+
server.shell(
|
|
36
|
+
name="Turn off swapfile",
|
|
37
|
+
commands=["swapoff /swapfile"],
|
|
38
|
+
_sudo=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
files.file(
|
|
42
|
+
name="Remove swapfile",
|
|
43
|
+
path="/swapfile",
|
|
44
|
+
present=False,
|
|
45
|
+
_sudo=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
files.line(
|
|
49
|
+
name="Remove swapfile from fstab",
|
|
50
|
+
path="/etc/fstab",
|
|
51
|
+
line="/swapfile none swap sw 0 0",
|
|
52
|
+
present=False,
|
|
53
|
+
_sudo=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _disable_zram() -> None:
|
|
58
|
+
pm = get_pm()
|
|
59
|
+
pm.install(
|
|
60
|
+
name="Remove zram-tools",
|
|
61
|
+
packages=["zram-tools"],
|
|
62
|
+
present=False,
|
|
63
|
+
_sudo=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _set_swappiness(value: int) -> None:
|
|
68
|
+
server.sysctl(
|
|
69
|
+
name=f"Set swappiness to {value}",
|
|
70
|
+
key="vm.swappiness",
|
|
71
|
+
value=value,
|
|
72
|
+
persist=True,
|
|
73
|
+
persist_file="/etc/sysctl.d/99-swappiness.conf",
|
|
74
|
+
_sudo=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _configure_zram(size: str, algo: SwapAlgo) -> None:
|
|
79
|
+
pm = get_pm()
|
|
80
|
+
pm.install(
|
|
81
|
+
name="Install zram-tools",
|
|
82
|
+
packages=["zram-tools"],
|
|
83
|
+
_sudo=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
zram_config = files.template(
|
|
87
|
+
name="Configure zram-tools",
|
|
88
|
+
src=get_template_path("etc/default/zramswap.j2"),
|
|
89
|
+
dest="/etc/default/zramswap",
|
|
90
|
+
mode="0644",
|
|
91
|
+
jinja_env_kwargs=BLOCK_TRIM_ENV,
|
|
92
|
+
algo=algo,
|
|
93
|
+
size=size,
|
|
94
|
+
_sudo=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
systemd.service(
|
|
98
|
+
name="Restart zramswap on change",
|
|
99
|
+
service="zramswap",
|
|
100
|
+
restarted=True,
|
|
101
|
+
_sudo=True,
|
|
102
|
+
_if=zram_config.did_change,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
systemd.service(
|
|
106
|
+
name="Ensure zramswap is running and enabled",
|
|
107
|
+
service="zramswap",
|
|
108
|
+
running=True,
|
|
109
|
+
enabled=True,
|
|
110
|
+
_sudo=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _configure_basic_swap(size: str) -> None:
|
|
115
|
+
if not host.get_fact(File, "/swapfile"):
|
|
116
|
+
server.shell(
|
|
117
|
+
name=f"Create swapfile of size {size}",
|
|
118
|
+
commands=[
|
|
119
|
+
f"fallocate -l {size} /swapfile",
|
|
120
|
+
"chmod 600 /swapfile",
|
|
121
|
+
"mkswap /swapfile",
|
|
122
|
+
"swapon /swapfile",
|
|
123
|
+
],
|
|
124
|
+
_sudo=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
files.line(
|
|
128
|
+
name="Add swapfile to fstab",
|
|
129
|
+
path="/etc/fstab",
|
|
130
|
+
line="/swapfile none swap sw 0 0",
|
|
131
|
+
_sudo=True,
|
|
132
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""System utilities for NullForge."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from pyinfra.context import host
|
|
6
|
+
from pyinfra.facts.files import FileContents
|
|
7
|
+
from pyinfra.facts.hardware import Memory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_LOCALE_GEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9-]+)?(?:@[A-Za-z0-9-]+)?\s+[A-Z][A-Z0-9-]*")
|
|
11
|
+
"""A `locale.gen` entry: `name[.charset][@modifier] CHARMAP`, e.g. `en_US.UTF-8 UTF-8`.
|
|
12
|
+
|
|
13
|
+
The commented-out entries this file is mostly made of are indistinguishable from its prose
|
|
14
|
+
header by shape alone, so both tokens are matched rather than just counted.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_total_memory(default: int = 1024) -> int:
|
|
19
|
+
"""Return host's total RAM in MB."""
|
|
20
|
+
|
|
21
|
+
cache_key = "_nullforge_total_memory_mb"
|
|
22
|
+
if hasattr(host.data, cache_key):
|
|
23
|
+
return getattr(host.data, cache_key)
|
|
24
|
+
|
|
25
|
+
memory = host.get_fact(Memory)
|
|
26
|
+
total_mb = int(memory) if memory else default
|
|
27
|
+
|
|
28
|
+
setattr(host.data, cache_key, total_mb)
|
|
29
|
+
return total_mb
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_supported_locales() -> list[str]:
|
|
33
|
+
"""Get list of supported locales from /etc/locale.gen."""
|
|
34
|
+
|
|
35
|
+
cache_key = "_nullforge_supported_locales"
|
|
36
|
+
if hasattr(host.data, cache_key):
|
|
37
|
+
return getattr(host.data, cache_key)
|
|
38
|
+
|
|
39
|
+
locales = []
|
|
40
|
+
for line in host.get_fact(FileContents, path="/etc/locale.gen") or []:
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
if line.startswith("#"):
|
|
45
|
+
line = line[1:].strip()
|
|
46
|
+
|
|
47
|
+
if _LOCALE_GEN_RE.fullmatch(line):
|
|
48
|
+
name, charmap = line.split()
|
|
49
|
+
locales.append(f"{name} {charmap}")
|
|
50
|
+
|
|
51
|
+
setattr(host.data, cache_key, locales)
|
|
52
|
+
return locales
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def detect_best_locale(preferred: str | None = None) -> str | None:
|
|
56
|
+
"""Return the best available locale gen line, or ``None`` if none are supported.
|
|
57
|
+
|
|
58
|
+
A ``preferred`` locale is honored when available (by exact or name-prefix match);
|
|
59
|
+
otherwise a sensible UTF-8 default is chosen. ``None`` is returned only when the
|
|
60
|
+
host reports no supported locales at all, so callers never need a separate
|
|
61
|
+
fallback call.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
supported = get_supported_locales()
|
|
65
|
+
if not supported:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
preferred = (preferred or "").strip()
|
|
69
|
+
if preferred:
|
|
70
|
+
if preferred in supported:
|
|
71
|
+
return preferred
|
|
72
|
+
pref_name = preferred.split()[0]
|
|
73
|
+
for supp in supported:
|
|
74
|
+
if supp.startswith(pref_name):
|
|
75
|
+
return supp
|
|
76
|
+
|
|
77
|
+
defaults = ["en_US.UTF-8 UTF-8", "C.UTF-8 UTF-8", "en_GB.UTF-8 UTF-8"]
|
|
78
|
+
for def_loc in defaults:
|
|
79
|
+
if def_loc in supported:
|
|
80
|
+
return def_loc
|
|
81
|
+
|
|
82
|
+
for loc in supported:
|
|
83
|
+
if "UTF-8" in loc.upper():
|
|
84
|
+
return loc
|
|
85
|
+
|
|
86
|
+
return supported[0]
|