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.
Files changed (99) hide show
  1. nullforge/__init__.py +1 -0
  2. nullforge/foundry/README.md +0 -0
  3. nullforge/foundry/__init__.py +7 -0
  4. nullforge/foundry/full_cast.py +22 -0
  5. nullforge/inventories/README.md +0 -0
  6. nullforge/inventories/example.py +87 -0
  7. nullforge/models/__init__.py +1 -0
  8. nullforge/models/containers.py +60 -0
  9. nullforge/models/dns.py +222 -0
  10. nullforge/models/monitoring/__init__.py +29 -0
  11. nullforge/models/monitoring/base.py +19 -0
  12. nullforge/models/monitoring/nezha.py +13 -0
  13. nullforge/models/netsec.py +43 -0
  14. nullforge/models/profiles.py +33 -0
  15. nullforge/models/system.py +15 -0
  16. nullforge/models/users.py +9 -0
  17. nullforge/models/warp.py +65 -0
  18. nullforge/models/zerotrust.py +9 -0
  19. nullforge/molds/__init__.py +38 -0
  20. nullforge/molds/base_mold.py +59 -0
  21. nullforge/molds/containers.py +26 -0
  22. nullforge/molds/defaults.py +10 -0
  23. nullforge/molds/dns.py +70 -0
  24. nullforge/molds/features.py +75 -0
  25. nullforge/molds/haproxy.py +23 -0
  26. nullforge/molds/monitoring/__init__.py +45 -0
  27. nullforge/molds/monitoring/nezha.py +118 -0
  28. nullforge/molds/netsec.py +237 -0
  29. nullforge/molds/profiles.py +30 -0
  30. nullforge/molds/system.py +140 -0
  31. nullforge/molds/telemt.py +140 -0
  32. nullforge/molds/tor.py +28 -0
  33. nullforge/molds/user.py +77 -0
  34. nullforge/molds/utils.py +115 -0
  35. nullforge/molds/warp.py +54 -0
  36. nullforge/molds/xray.py +16 -0
  37. nullforge/molds/zerotrust.py +59 -0
  38. nullforge/runes/__init__.py +46 -0
  39. nullforge/runes/base.py +327 -0
  40. nullforge/runes/containers.py +212 -0
  41. nullforge/runes/dns.py +256 -0
  42. nullforge/runes/haproxy.py +135 -0
  43. nullforge/runes/monitoring.py +21 -0
  44. nullforge/runes/netsec.py +547 -0
  45. nullforge/runes/prepare.py +30 -0
  46. nullforge/runes/profiles.py +583 -0
  47. nullforge/runes/telemt.py +252 -0
  48. nullforge/runes/tor.py +57 -0
  49. nullforge/runes/users.py +159 -0
  50. nullforge/runes/warp.py +273 -0
  51. nullforge/runes/xray.py +81 -0
  52. nullforge/runes/zerotrust.py +91 -0
  53. nullforge/smithy/__init__.py +1 -0
  54. nullforge/smithy/admin.py +40 -0
  55. nullforge/smithy/arch.py +36 -0
  56. nullforge/smithy/blocky.py +37 -0
  57. nullforge/smithy/cloudflare.py +32 -0
  58. nullforge/smithy/github.py +256 -0
  59. nullforge/smithy/http.py +85 -0
  60. nullforge/smithy/install.py +149 -0
  61. nullforge/smithy/monitoring/__init__.py +1 -0
  62. nullforge/smithy/monitoring/nezha/__init__.py +0 -0
  63. nullforge/smithy/monitoring/nezha/agent.py +54 -0
  64. nullforge/smithy/monitoring/nezha/dashboard.py +137 -0
  65. nullforge/smithy/monitoring/nezha/deploy.py +81 -0
  66. nullforge/smithy/network.py +95 -0
  67. nullforge/smithy/packages.py +176 -0
  68. nullforge/smithy/service.py +37 -0
  69. nullforge/smithy/sni.py +358 -0
  70. nullforge/smithy/swap.py +132 -0
  71. nullforge/smithy/system.py +86 -0
  72. nullforge/smithy/versions.py +282 -0
  73. nullforge/templates/__init__.py +32 -0
  74. nullforge/templates/cloudflared/tunnel.yml.j2 +6 -0
  75. nullforge/templates/dns/blocky.yaml.j2 +11 -0
  76. nullforge/templates/dns/dns.yaml.j2 +6 -0
  77. nullforge/templates/dns/resolv.conf.j2 +1 -0
  78. nullforge/templates/dns/resolved.conf.j2 +13 -0
  79. nullforge/templates/etc/default/zramswap.j2 +13 -0
  80. nullforge/templates/nvim/nvim_patch.lua.j2 +5 -0
  81. nullforge/templates/profiles/direnv.toml +2 -0
  82. nullforge/templates/profiles/starship.toml +186 -0
  83. nullforge/templates/profiles/tmux.conf +32 -0
  84. nullforge/templates/profiles/zshrc.j2 +46 -0
  85. nullforge/templates/scripts/telemt-synfix.sh +89 -0
  86. nullforge/templates/scripts/teleproxy-warp.sh +127 -0
  87. nullforge/templates/scripts/warp-v6-policy.sh +118 -0
  88. nullforge/templates/scripts/zt-tunnel-warp.sh +28 -0
  89. nullforge/templates/systemd/blocky.service.j2 +19 -0
  90. nullforge/templates/systemd/cloudflare-tunnel.service.j2 +27 -0
  91. nullforge/templates/systemd/cloudflare-warp.service.j2 +21 -0
  92. nullforge/templates/systemd/dns-internal.service.j2 +15 -0
  93. nullforge/templates/systemd/telemt.service.j2 +36 -0
  94. nullforge/templates/telemt/telemt.toml.j2 +49 -0
  95. nullforge/templates/tor/torrc.j2 +5 -0
  96. nullforge-0.1.0.dist-info/METADATA +39 -0
  97. nullforge-0.1.0.dist-info/RECORD +99 -0
  98. nullforge-0.1.0.dist-info/WHEEL +4 -0
  99. nullforge-0.1.0.dist-info/licenses/LICENSE +7 -0
@@ -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)
@@ -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]