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,547 @@
1
+ """Network security and hardening deployment module."""
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+
7
+ from pyinfra.api.operation import OperationMeta
8
+ from pyinfra.context import host
9
+ from pyinfra.facts.files import File, FileContents
10
+ from pyinfra.facts.server import Command
11
+ from pyinfra.operations import files, server, systemd
12
+ from pyinfra.operations.util import any_changed
13
+
14
+ from nullforge.models.netsec import (
15
+ CLASSICAL_KEX_ALGORITHMS,
16
+ HOST_KEY_ALGORITHMS,
17
+ MIN_DH_MODULUS_SIZE,
18
+ PQ_KEX_ALGORITHMS,
19
+ WEAK_KEX_PATTERNS,
20
+ WEAK_MAC_PATTERNS,
21
+ )
22
+ from nullforge.molds import FeaturesMold, FirewallRule, NetSecMold, SshMold, UserMold
23
+ from nullforge.smithy.network import conntrack_max_for, has_ipv6, module_loaded
24
+ from nullforge.smithy.packages import get_pm
25
+ from nullforge.smithy.system import get_total_memory
26
+
27
+
28
+ SSHD_CONFIG_PATH = "/etc/ssh/sshd_config"
29
+ SSH_MODULI_PATH = "/etc/ssh/moduli"
30
+
31
+ SSHD_DROPIN_PATH = "/etc/ssh/sshd_config.d/40-nullforge.conf"
32
+ """Sorted before distro drop-ins (50-redhat.conf, 50-cloud-init.conf) so first-obtained directives win."""
33
+
34
+
35
+ def _read_checksum_file(path: str) -> str:
36
+ """Read checksum file, returning empty string when absent."""
37
+
38
+ contents = host.get_fact(FileContents, path=path, _sudo=True)
39
+ return contents[0].strip() if contents else ""
40
+
41
+
42
+ def deploy_network_security() -> None:
43
+ """Deploy network security and hardening configuration."""
44
+
45
+ features: FeaturesMold = host.data.features
46
+ user_opts: UserMold = features.users
47
+ netsec_opts: NetSecMold = features.netsec
48
+
49
+ _enhance_ssh_daemon(user_opts, netsec_opts.ssh)
50
+
51
+ if netsec_opts.firewall:
52
+ pm = get_pm()
53
+ if pm.is_rhel_based:
54
+ _configure_firewalld_firewall(netsec_opts)
55
+ else:
56
+ _configure_ufw_firewall(netsec_opts)
57
+
58
+ _apply_sysctl_tuning(netsec_opts)
59
+
60
+
61
+ def _rules_checksum(rules: list[FirewallRule]) -> str:
62
+ """Compute a deterministic SHA-256 of the desired active ruleset.
63
+
64
+ Rules are serialised as sorted JSON objects and then sorted themselves so
65
+ order in inventory does not affect fingerprint. Any change to ruleset:
66
+ add rule, remove rule, modify field will produces different hash,
67
+ triggering a full UFW reset on next run.
68
+ """
69
+
70
+ fingerprints = sorted(json.dumps(r.model_dump(), sort_keys=True) for r in rules)
71
+ payload = "\n".join(fingerprints)
72
+ return hashlib.sha256(payload.encode()).hexdigest()
73
+
74
+
75
+ def _build_ufw_command(rule: FirewallRule) -> str:
76
+ """Build complete `ufw` CLI command string from `FirewallRule`.
77
+
78
+ Produces the canonical long-form command:
79
+ ufw ACTION [DIRECTION] [on IFACE] [proto PROTO]
80
+ from ADDR to ADDR [port PORT] [comment "LABEL"]
81
+ """
82
+
83
+ parts: list[str] = ["ufw", rule.action]
84
+
85
+ if rule.direction:
86
+ parts.append(rule.direction)
87
+
88
+ if rule.interface:
89
+ parts += ["on", rule.interface]
90
+
91
+ if rule.proto != "any":
92
+ parts += ["proto", rule.proto]
93
+
94
+ parts += ["from", rule.from_ip or "any"]
95
+ parts += ["to", rule.to_ip or "any"]
96
+
97
+ if rule.port is not None:
98
+ parts += ["port", str(rule.port)]
99
+
100
+ if rule.comment:
101
+ parts += ["comment", f'"{rule.comment}"']
102
+
103
+ return " ".join(parts)
104
+
105
+
106
+ def _configure_ufw_firewall(opts: NetSecMold) -> None:
107
+ """Configure UFW firewall with the specified rule set.
108
+
109
+ IPv6-targeted rules (those whose source/destination is an IPv6 address or
110
+ subnet) are silently dropped when the host has no IPv6 connectivity.
111
+ """
112
+
113
+ ipv6 = has_ipv6()
114
+ active_rules = [r for r in opts.firewall_rules if ipv6 or not r.is_ipv6]
115
+ checksum = _rules_checksum(active_rules)
116
+ checksum_file = "/etc/ufw/.nullforge_checksum"
117
+
118
+ pm = get_pm()
119
+ pm.install(
120
+ name="Install UFW firewall",
121
+ packages=["ufw"],
122
+ _sudo=True,
123
+ )
124
+
125
+ cmd_get_ufw = "ufw status 2>/dev/null || true"
126
+ if not opts.reinstall:
127
+ ufw_status = host.get_fact(Command, cmd_get_ufw, _sudo=True) or ""
128
+ if "Status: active" in ufw_status and _read_checksum_file(checksum_file) == checksum:
129
+ return
130
+
131
+ server.shell(
132
+ name="Reset UFW to clean state",
133
+ commands=[
134
+ "ufw --force reset",
135
+ ],
136
+ _sudo=True,
137
+ )
138
+
139
+ ipv6_value = "yes" if ipv6 else "no"
140
+ server.shell(
141
+ name=f"Configure UFW IPv6={ipv6_value}",
142
+ commands=[
143
+ f"sed -i 's/^IPV6=.*/IPV6={ipv6_value}/' /etc/default/ufw",
144
+ ],
145
+ _sudo=True,
146
+ )
147
+
148
+ server.shell(
149
+ name="Set UFW default policies",
150
+ commands=[
151
+ "ufw default deny incoming",
152
+ "ufw default allow outgoing",
153
+ "ufw default allow forward",
154
+ ],
155
+ _sudo=True,
156
+ )
157
+
158
+ for rule in host.loop(active_rules):
159
+ cmd = _build_ufw_command(rule)
160
+ label = f"UFW {rule.action}: {rule.comment or cmd}"
161
+ server.shell(
162
+ name=label,
163
+ commands=[cmd],
164
+ _sudo=True,
165
+ )
166
+
167
+ server.shell(
168
+ name="Enable UFW firewall",
169
+ commands=[
170
+ "yes | ufw enable",
171
+ ],
172
+ _sudo=True,
173
+ )
174
+
175
+ server.shell(
176
+ name="Persist UFW ruleset checksum",
177
+ commands=[
178
+ f"printf '%s' '{checksum}' > {checksum_file}",
179
+ ],
180
+ _sudo=True,
181
+ )
182
+
183
+
184
+ def _build_firewalld_commands(rule: FirewallRule) -> list[str]:
185
+ """Build firewall-cmd command strings from a FirewallRule.
186
+
187
+ Returns one or two commands depending on protocol.
188
+ Raises ValueError for direction='out' and action='limit' rules (not supported on firewalld).
189
+ """
190
+
191
+ if rule.direction == "out":
192
+ raise ValueError(
193
+ f"direction='out' rules are not supported on firewalld (port={rule.port}, comment={rule.comment!r})"
194
+ )
195
+
196
+ if rule.action == "limit":
197
+ raise ValueError(
198
+ f"action='limit' rules are not supported on firewalld (port={rule.port}, comment={rule.comment!r})"
199
+ )
200
+
201
+ action_map = {"allow": "accept", "deny": "drop", "reject": "reject"}
202
+ fw_action = action_map[rule.action]
203
+ zone = "public"
204
+
205
+ # Simple port-allow with no IP/interface constraints — use --add-port (cleaner)
206
+ if (
207
+ rule.action == "allow"
208
+ and rule.from_ip is None
209
+ and rule.to_ip is None
210
+ and rule.interface is None
211
+ and rule.port is not None
212
+ ):
213
+ if rule.proto == "any":
214
+ return [
215
+ f"firewall-cmd --permanent --zone={zone} --add-port={rule.port}/tcp",
216
+ f"firewall-cmd --permanent --zone={zone} --add-port={rule.port}/udp",
217
+ ]
218
+ return [f"firewall-cmd --permanent --zone={zone} --add-port={rule.port}/{rule.proto}"]
219
+
220
+ # Rich rule for all other cases
221
+ def make_rich_rule(family: str, proto: str | None) -> str:
222
+ parts = [f'rule family="{family}"']
223
+ if rule.from_ip:
224
+ parts.append(f'source address="{rule.from_ip}"')
225
+ if rule.to_ip:
226
+ parts.append(f'destination address="{rule.to_ip}"')
227
+ if rule.port is not None and proto is not None:
228
+ parts.append(f'port port="{rule.port}" protocol="{proto}"')
229
+ parts.append(fw_action)
230
+ return " ".join(parts)
231
+
232
+ family = "ipv6" if rule.is_ipv6 else "ipv4"
233
+
234
+ if rule.proto == "any" and rule.port is not None:
235
+ return [
236
+ f"firewall-cmd --permanent --zone={zone} --add-rich-rule='{make_rich_rule(family, 'tcp')}'",
237
+ f"firewall-cmd --permanent --zone={zone} --add-rich-rule='{make_rich_rule(family, 'udp')}'",
238
+ ]
239
+
240
+ proto = rule.proto if rule.proto != "any" else None
241
+ return [f"firewall-cmd --permanent --zone={zone} --add-rich-rule='{make_rich_rule(family, proto)}'"]
242
+
243
+
244
+ def _configure_firewalld_firewall(opts: NetSecMold) -> None:
245
+ """Configure firewalld with the specified rule set.
246
+
247
+ IPv6-targeted rules are silently dropped when the host has no IPv6 connectivity.
248
+ direction='out' rules raise ValueError as egress control requires firewalld policy objects.
249
+ action='limit' rules raise ValueError as firewalld has no equivalent of ufw's rate limiting.
250
+ """
251
+
252
+ ipv6 = has_ipv6()
253
+ active_rules = [r for r in opts.firewall_rules if ipv6 or not r.is_ipv6]
254
+ checksum = _rules_checksum(active_rules)
255
+ checksum_file = "/etc/firewalld/.nullforge_checksum"
256
+
257
+ pm = get_pm()
258
+ pm.install(
259
+ name="Install firewalld",
260
+ packages=["firewalld"],
261
+ _sudo=True,
262
+ )
263
+
264
+ systemd.service(
265
+ name="Ensure firewalld is running and enabled",
266
+ service="firewalld",
267
+ running=True,
268
+ enabled=True,
269
+ _sudo=True,
270
+ )
271
+
272
+ if not opts.reinstall and _read_checksum_file(checksum_file) == checksum:
273
+ return
274
+
275
+ server.shell(
276
+ name="Flush firewalld public zone rich rules",
277
+ commands=[
278
+ "firewall-cmd --permanent --zone=public --list-rich-rules"
279
+ " | xargs -r -d '\\n' -I _RULE"
280
+ " firewall-cmd --permanent --zone=public --remove-rich-rule=_RULE",
281
+ ],
282
+ _sudo=True,
283
+ )
284
+
285
+ server.shell(
286
+ name="Flush firewalld public zone ports",
287
+ commands=[
288
+ "firewall-cmd --permanent --zone=public --list-ports"
289
+ " | tr ' ' '\\n'"
290
+ " | xargs -r -I _PORT"
291
+ " firewall-cmd --permanent --zone=public --remove-port=_PORT",
292
+ ],
293
+ _sudo=True,
294
+ )
295
+
296
+ server.shell(
297
+ name="Set firewalld default zone policy to DROP",
298
+ commands=["firewall-cmd --permanent --zone=public --set-target=DROP"],
299
+ _sudo=True,
300
+ )
301
+
302
+ for rule in host.loop(active_rules):
303
+ cmds = _build_firewalld_commands(rule)
304
+ label = f"firewalld {rule.action}: {rule.comment or rule.port}"
305
+ server.shell(
306
+ name=label,
307
+ commands=cmds,
308
+ _sudo=True,
309
+ )
310
+
311
+ server.shell(
312
+ name="Reload firewalld",
313
+ commands=["firewall-cmd --reload"],
314
+ _sudo=True,
315
+ )
316
+
317
+ server.shell(
318
+ name="Persist firewalld ruleset checksum",
319
+ commands=[f"printf '%s' '{checksum}' > {checksum_file}"],
320
+ _sudo=True,
321
+ )
322
+
323
+
324
+ def _supported_kex_algorithms() -> frozenset[str]:
325
+ raw = host.get_fact(Command, "ssh -Q kex 2>/dev/null || true") or ""
326
+ return frozenset(line.strip() for line in raw.splitlines() if line.strip())
327
+
328
+
329
+ def _kex_directive(ssh_opts: SshMold, supported_kex: frozenset[str]) -> str | None:
330
+ """Single KexAlgorithms value combining PQ priority and weak-algorithm removal."""
331
+
332
+ pq = [algo for algo in PQ_KEX_ALGORITHMS if algo in supported_kex] if ssh_opts.pq_kex_priority else []
333
+
334
+ if ssh_opts.strip_weak_algorithms:
335
+ classical = [algo for algo in CLASSICAL_KEX_ALGORITHMS if algo in supported_kex]
336
+ if pq and classical:
337
+ return ",".join((*pq, *classical))
338
+ return f"-{WEAK_KEX_PATTERNS}"
339
+
340
+ if pq:
341
+ return "^" + ",".join(pq)
342
+ return None
343
+
344
+
345
+ def _build_sshd_dropin(ssh_opts: SshMold, supported_kex: frozenset[str]) -> str:
346
+ """Prepare sshd drop-in file content."""
347
+
348
+ lines = [f"HostKey /etc/ssh/ssh_host_{key_type}_key" for key_type in ssh_opts.host_keys or []]
349
+ if ssh_opts.host_keys:
350
+ offered = [algo for key_type in ssh_opts.host_keys for algo in HOST_KEY_ALGORITHMS[key_type]]
351
+ lines.append("HostKeyAlgorithms " + ",".join(offered))
352
+
353
+ kex = _kex_directive(ssh_opts, supported_kex)
354
+ if kex:
355
+ lines.append(f"KexAlgorithms {kex}")
356
+
357
+ if ssh_opts.strip_weak_algorithms:
358
+ lines.append(f"MACs -{WEAK_MAC_PATTERNS}")
359
+ lines.append("CASignatureAlgorithms -ssh-rsa")
360
+
361
+ if not lines:
362
+ return ""
363
+
364
+ header = "# Managed by NullForge - do not edit, changes are overwritten on deploy."
365
+ return "\n".join([header, *lines]) + "\n"
366
+
367
+
368
+ def _configure_sshd_dropin(ssh_opts: SshMold) -> list[OperationMeta]:
369
+ """Deploy sshd drop-in pinning offered host keys and key-exchange priority."""
370
+
371
+ content = _build_sshd_dropin(
372
+ ssh_opts,
373
+ _supported_kex_algorithms(),
374
+ )
375
+
376
+ if not content:
377
+ return [
378
+ files.file(
379
+ name="Remove sshd drop-in",
380
+ path=SSHD_DROPIN_PATH,
381
+ present=False,
382
+ _sudo=True,
383
+ )
384
+ ]
385
+
386
+ ops: list[OperationMeta] = []
387
+
388
+ for key_type in host.loop(ssh_opts.host_keys or []):
389
+ key_path = f"/etc/ssh/ssh_host_{key_type}_key"
390
+ if host.get_fact(File, path=key_path, _sudo=True):
391
+ continue
392
+ ops.append(
393
+ server.shell(
394
+ name=f"Generate {key_type} host key",
395
+ commands=[f"ssh-keygen -q -t {key_type} -f {key_path} -N ''"],
396
+ _sudo=True,
397
+ )
398
+ )
399
+
400
+ ops.append(
401
+ files.line(
402
+ name="Include drop-in directory",
403
+ path=SSHD_CONFIG_PATH,
404
+ line=r"^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf",
405
+ replace="Include /etc/ssh/sshd_config.d/*.conf",
406
+ extended_regex=True,
407
+ _sudo=True,
408
+ )
409
+ )
410
+ ops.append(
411
+ files.put(
412
+ name="Configure sshd drop-in",
413
+ src=io.StringIO(content),
414
+ dest=SSHD_DROPIN_PATH,
415
+ mode="0600",
416
+ create_remote_dir=True,
417
+ _sudo=True,
418
+ )
419
+ )
420
+ return ops
421
+
422
+
423
+ def _filter_dh_moduli() -> OperationMeta | None:
424
+ """Drop sub-3072-bit DH group-exchange moduli (sshd picks from this file at negotiation time)."""
425
+
426
+ weak = (
427
+ host.get_fact(
428
+ Command,
429
+ f"awk '$5+0 > 0 && $5+0 < {MIN_DH_MODULUS_SIZE} {{print; exit}}' {SSH_MODULI_PATH} 2>/dev/null || true",
430
+ _sudo=True,
431
+ )
432
+ or ""
433
+ )
434
+ if not str(weak).strip():
435
+ return None
436
+
437
+ return server.shell(
438
+ name="Drop weak DH group-exchange moduli",
439
+ commands=[
440
+ f"awk '/^#/ || $5+0 >= {MIN_DH_MODULUS_SIZE}' {SSH_MODULI_PATH} > {SSH_MODULI_PATH}.tmp"
441
+ f" && test -s {SSH_MODULI_PATH}.tmp"
442
+ f" && mv {SSH_MODULI_PATH}.tmp {SSH_MODULI_PATH}",
443
+ ],
444
+ _sudo=True,
445
+ )
446
+
447
+
448
+ def _enhance_ssh_daemon(user_opts: UserMold, ssh_opts: SshMold) -> None:
449
+ """Enhance SSH daemon configuration."""
450
+
451
+ pm = get_pm()
452
+
453
+ sshd_directives = [
454
+ ("Modify SSH password authentication", "PasswordAuthentication", "no", user_opts.manage),
455
+ ("Disable SSH root login", "PermitRootLogin", "no", True),
456
+ ("Enable DNS resolution for SSH", "UseDNS", "yes", True),
457
+ ]
458
+
459
+ config_ops: list[OperationMeta] = []
460
+ for op_name, directive, value, should_apply in host.loop(sshd_directives):
461
+ if not should_apply:
462
+ continue
463
+ config_ops.append(
464
+ files.line(
465
+ name=op_name,
466
+ path=SSHD_CONFIG_PATH,
467
+ line=rf"^[[:space:]]*#?[[:space:]]*{directive}[[:space:]]+.*",
468
+ replace=f"{directive} {value}",
469
+ extended_regex=True,
470
+ _sudo=True,
471
+ )
472
+ )
473
+
474
+ config_ops.extend(_configure_sshd_dropin(ssh_opts))
475
+
476
+ if ssh_opts.strip_weak_algorithms:
477
+ moduli_op = _filter_dh_moduli()
478
+ if moduli_op:
479
+ config_ops.append(moduli_op)
480
+
481
+ if config_ops:
482
+ server.shell(
483
+ name="Validate sshd configuration",
484
+ commands=["/usr/sbin/sshd -t"],
485
+ _sudo=True,
486
+ _if=any_changed(*config_ops),
487
+ )
488
+
489
+ service_name = "ssh" if pm.is_debian_based else "sshd"
490
+ systemd.service(
491
+ name="Restart SSH on change",
492
+ service=service_name,
493
+ restarted=True,
494
+ _sudo=True,
495
+ _if=any_changed(*config_ops),
496
+ )
497
+
498
+ systemd.service(
499
+ name="Ensure SSH is running and enabled",
500
+ service=service_name,
501
+ running=True,
502
+ enabled=True,
503
+ _sudo=True,
504
+ )
505
+
506
+
507
+ def _resolve_conntrack_sysctls(configured: dict[str, int] | None) -> dict[str, int] | None:
508
+ if not configured:
509
+ return configured
510
+
511
+ if not module_loaded("nf_conntrack"):
512
+ return None
513
+
514
+ ct_max_target = conntrack_max_for(get_total_memory())
515
+ ct_buckets = max(4096, (ct_max_target + 3) // 4)
516
+ runtime_sizing = {
517
+ "net.netfilter.nf_conntrack_max": ct_buckets * 4,
518
+ }
519
+ return {**runtime_sizing, **configured}
520
+
521
+
522
+ def _apply_sysctl_tuning(opts: NetSecMold) -> None:
523
+ """Apply system kernel parameter tuning."""
524
+
525
+ persist_map = [
526
+ ("system", opts.system_sysctl, "/etc/sysctl.d/99-system.conf"),
527
+ ("conntrack", _resolve_conntrack_sysctls(opts.conntrack_sysctl), "/etc/sysctl.d/99-conntrack.conf"),
528
+ ("ipv4", opts.ipv4_sysctl, "/etc/sysctl.d/99-ipv4.conf"),
529
+ ("ipv6", opts.ipv6_sysctl if has_ipv6() else None, "/etc/sysctl.d/99-ipv6.conf"),
530
+ ]
531
+
532
+ for desc, sysctls, persist_file in host.loop(persist_map):
533
+ if not sysctls:
534
+ continue
535
+
536
+ for key, value in host.loop(sysctls.items()):
537
+ server.sysctl(
538
+ name=f"Set sysctl {key} ({desc})",
539
+ key=key,
540
+ value=str(value),
541
+ persist=True,
542
+ persist_file=persist_file,
543
+ _sudo=True,
544
+ )
545
+
546
+
547
+ deploy_network_security()
@@ -0,0 +1,30 @@
1
+ """Prepare the system for deployment."""
2
+
3
+ from nullforge.smithy.admin import is_root
4
+ from nullforge.smithy.packages import get_pm
5
+
6
+
7
+ def deploy_prepare() -> None:
8
+ if is_root():
9
+ _prepare_sudo()
10
+
11
+
12
+ def _prepare_sudo() -> None:
13
+ """As some distros don't have sudo installed by default, we ensure to have it."""
14
+
15
+ pm = get_pm()
16
+
17
+ pm.update(
18
+ name="Update package lists",
19
+ )
20
+
21
+ pm.install(
22
+ name="Install minimal packages",
23
+ packages=[
24
+ "sudo",
25
+ "locales",
26
+ ],
27
+ )
28
+
29
+
30
+ deploy_prepare()