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,237 @@
1
+ """Network security and hardening configuration mold."""
2
+
3
+ from ipaddress import IPv6Network, ip_network
4
+ from typing import Literal
5
+
6
+ from pydantic import Field, field_validator
7
+
8
+ from nullforge.models.netsec import SshHostKeyType
9
+
10
+ from .base_mold import BaseMold
11
+
12
+
13
+ SSH_PORT = 22
14
+ """Default SSH port."""
15
+
16
+
17
+ class SshMold(BaseMold):
18
+ """SSH daemon key material and key-exchange configuration."""
19
+
20
+ host_keys: list[SshHostKeyType] | None = Field(
21
+ default=None,
22
+ description="Host key types sshd offers, one HostKey directive per entry. "
23
+ "None keeps distro default set; missing key files are generated.",
24
+ )
25
+ pq_kex_priority: bool = Field(
26
+ default=True,
27
+ description="Prefer post-quantum hybrid key exchange by prepending PQ algorithms",
28
+ )
29
+ strip_weak_algorithms: bool = Field(
30
+ default=True,
31
+ description="Remove weak algorithms from the sshd offer and sub-3072-bit DH group-exchange moduli.",
32
+ )
33
+
34
+ @field_validator("host_keys")
35
+ @classmethod
36
+ def _validate_host_keys(cls, value: list[SshHostKeyType] | None) -> list[SshHostKeyType] | None:
37
+ if value is None:
38
+ return value
39
+ if not value:
40
+ raise ValueError("host_keys cannot be empty: sshd needs at least one host key (use None for defaults)")
41
+ return list(dict.fromkeys(value))
42
+
43
+
44
+ class FirewallRule(BaseMold):
45
+ """A single UFW firewall rule, to ``ufw`` CLI invocation.
46
+
47
+ Fields map directly to UFW command:
48
+ ufw ACTION [DIRECTION] [on IFACE] [proto PROTO]
49
+ from FROM_IP to TO_IP [port PORT] [comment "COMMENT"]
50
+ """
51
+
52
+ port: int | str | None = Field(
53
+ default=None,
54
+ description="Port number (22), range string ('8080:8090'), or None for IP-only rules",
55
+ )
56
+ proto: Literal["tcp", "udp", "any"] = Field(
57
+ default="any",
58
+ description="Protocol to match; 'any' omits the proto clause entirely",
59
+ )
60
+ from_ip: str | None = Field(
61
+ default=None,
62
+ description="Source IP address or CIDR (IPv4 or IPv6). None resolves to 'any'",
63
+ )
64
+ to_ip: str | None = Field(
65
+ default=None,
66
+ description="Destination IP address or CIDR. None resolves to 'any'",
67
+ )
68
+ action: Literal["allow", "deny", "reject", "limit"] = Field(
69
+ default="allow",
70
+ description="UFW action to apply",
71
+ )
72
+ direction: Literal["in", "out"] | None = Field(
73
+ default=None,
74
+ description="Traffic direction; None lets UFW apply its default (in)",
75
+ )
76
+ interface: str | None = Field(
77
+ default=None,
78
+ description="Bind rule to a specific network interface, e.g. 'eth0'",
79
+ )
80
+ comment: str | None = Field(
81
+ default=None,
82
+ description="Optional label stored in UFW rule metadata",
83
+ )
84
+
85
+ @property
86
+ def is_ipv6(self) -> bool:
87
+ """True when this rule references IPv6 address exclusively.
88
+
89
+ Rules where from_ip and to_ip are None are considered protocol-agnostic
90
+ and are not classified as IPv6-only. Mixed rules that reference both IPv4
91
+ and IPv6 address are also not IPv6-only.
92
+ """
93
+
94
+ candidates = [a for a in (self.from_ip, self.to_ip) if a and a not in ("any", "anywhere")]
95
+ if not candidates:
96
+ return False
97
+ try:
98
+ networks = [ip_network(addr, strict=False) for addr in candidates]
99
+ except ValueError:
100
+ return False
101
+ return all(isinstance(net, IPv6Network) for net in networks)
102
+
103
+
104
+ def _default_firewall_rules() -> list[FirewallRule]:
105
+ """Default UFW ruleset — allow SSH from anywhere."""
106
+
107
+ return [FirewallRule(port=SSH_PORT, comment="SSH")]
108
+
109
+
110
+ def _default_system_sysctl() -> dict[str, int]:
111
+ """Get the default sysctl parameters."""
112
+
113
+ sysctl = {
114
+ # --- SYSTEM RESOURCE LIMITS ---
115
+ "fs.nr_open": 3000000,
116
+ "fs.file-max": 2097152,
117
+ "vm.swappiness": 10,
118
+ }
119
+
120
+ return sysctl
121
+
122
+
123
+ def _default_conntrack_sysctl() -> dict[str, int]:
124
+ """Get the default sysctl parameters for conntrack."""
125
+
126
+ return {
127
+ # --- TCP TIMEOUTS ---
128
+ "net.netfilter.nf_conntrack_tcp_timeout_established": 3600, # default 432000
129
+ "net.netfilter.nf_conntrack_tcp_timeout_time_wait": 30,
130
+ "net.netfilter.nf_conntrack_tcp_timeout_close_wait": 30,
131
+ "net.netfilter.nf_conntrack_generic_timeout": 120,
132
+ # --- UDP TIMEOUTS ---
133
+ "net.netfilter.nf_conntrack_udp_timeout": 30,
134
+ "net.netfilter.nf_conntrack_udp_timeout_stream": 120,
135
+ }
136
+
137
+
138
+ def _default_ipv4_sysctl() -> dict[str, str | int]:
139
+ """Get the default sysctl parameters for IPv4 stack."""
140
+
141
+ net_sysctl = {
142
+ # --- ROUTING & CORE ---
143
+ "net.ipv4.conf.all.rp_filter": 2,
144
+ "net.ipv4.conf.default.rp_filter": 2,
145
+ "net.core.default_qdisc": "fq",
146
+ "net.ipv4.tcp_congestion_control": "bbr",
147
+ # --- BACKLOGS ---
148
+ "net.core.somaxconn": 65535,
149
+ "net.core.netdev_max_backlog": 65535,
150
+ "net.ipv4.tcp_max_syn_backlog": 65535,
151
+ # --- PORT MANAGEMENT ---
152
+ "net.ipv4.ip_local_port_range": "15000 60999",
153
+ "net.ipv4.tcp_tw_reuse": 1,
154
+ "net.ipv4.tcp_fin_timeout": 30,
155
+ # --- MEMORY BUFFERS ---
156
+ "net.core.optmem_max": 65536,
157
+ "net.core.rmem_default": 262144,
158
+ "net.core.wmem_default": 262144,
159
+ "net.core.rmem_max": 67108864,
160
+ "net.core.wmem_max": 67108864,
161
+ "net.ipv4.tcp_rmem": "4096 87380 67108864",
162
+ "net.ipv4.tcp_wmem": "4096 65536 67108864",
163
+ "net.ipv4.udp_rmem_min": 16384,
164
+ "net.ipv4.udp_wmem_min": 16384,
165
+ # --- TCP FEATURES ---
166
+ "net.ipv4.tcp_mtu_probing": 1,
167
+ "net.ipv4.tcp_slow_start_after_idle": 0,
168
+ "net.ipv4.tcp_keepalive_time": 600,
169
+ "net.ipv4.tcp_keepalive_intvl": 30,
170
+ "net.ipv4.tcp_keepalive_probes": 3,
171
+ "net.ipv4.tcp_syncookies": 1,
172
+ "net.ipv4.tcp_fastopen": 3,
173
+ "net.ipv4.tcp_notsent_lowat": 16384,
174
+ }
175
+
176
+ return net_sysctl
177
+
178
+
179
+ def _default_ipv6_sysctl() -> dict[str, int]:
180
+ """Get the default sysctl parameters for IPv6 stack."""
181
+
182
+ net_sysctl = {
183
+ # --- NEIGHBOR DISCOVERY ---
184
+ "net.ipv6.neigh.default.gc_thresh1": 128,
185
+ "net.ipv6.neigh.default.gc_thresh2": 512,
186
+ "net.ipv6.neigh.default.gc_thresh3": 4096,
187
+ # --- BINDING ---
188
+ "net.ipv6.bindv6only": 0,
189
+ }
190
+
191
+ return net_sysctl
192
+
193
+
194
+ class NetSecMold(BaseMold):
195
+ """Full network configuration mold."""
196
+
197
+ install: bool = Field(
198
+ default=True,
199
+ description="Whether to apply network security and hardening",
200
+ )
201
+ firewall: bool = Field(
202
+ default=True,
203
+ description="Whether to enable firewall (UFW on Debian/Ubuntu, firewalld on RHEL)",
204
+ )
205
+ firewall_rules: list[FirewallRule] = Field(
206
+ default_factory=_default_firewall_rules,
207
+ description="Ordered list of firewall rules to apply. Rules with IPv6 addresses "
208
+ "are automatically skipped on hosts without IPv6 connectivity.",
209
+ )
210
+ ssh: SshMold = Field(
211
+ default_factory=SshMold,
212
+ description="SSH daemon host key and key-exchange configuration",
213
+ )
214
+ system_sysctl: dict[str, int] | None = Field(
215
+ default_factory=_default_system_sysctl,
216
+ description="Sysctl parameters to apply",
217
+ )
218
+ conntrack_sysctl: dict[str, int] | None = Field(
219
+ default_factory=_default_conntrack_sysctl,
220
+ description="Conntrack sysctl parameters to apply",
221
+ )
222
+ ipv4_sysctl: dict[str, str | int] | None = Field(
223
+ default_factory=_default_ipv4_sysctl,
224
+ description="IPv4 sysctl parameters to apply",
225
+ )
226
+ ipv6_sysctl: dict[str, int] | None = Field(
227
+ default_factory=_default_ipv6_sysctl,
228
+ description="IPv6 sysctl parameters to apply",
229
+ )
230
+ reinstall: bool = Field(
231
+ default=False,
232
+ description="Whether to force reconfigure the firewall even if already active",
233
+ )
234
+
235
+ @property
236
+ def is_active(self) -> bool:
237
+ return self.install
@@ -0,0 +1,30 @@
1
+ """Profiles configuration mold."""
2
+
3
+ from pydantic import Field
4
+
5
+ from nullforge.models.profiles import NerdFont
6
+
7
+ from .base_mold import BaseMold
8
+
9
+
10
+ class ProfilesMold(BaseMold):
11
+ for_root: bool = Field(
12
+ default=True,
13
+ description="Whether to install the profiles for the root user",
14
+ )
15
+ for_user: bool = Field(
16
+ default=False,
17
+ description="Whether to install the profiles for the user",
18
+ )
19
+ reinstall: bool = Field(
20
+ default=False,
21
+ description="Whether to reinstall profiles and tools even if already installed",
22
+ )
23
+ font: NerdFont | None = Field(
24
+ default=None,
25
+ description="Nerd Font family to install on the target; `None` skips font installation",
26
+ )
27
+
28
+ @property
29
+ def is_active(self) -> bool:
30
+ return self.for_root or self.for_user
@@ -0,0 +1,140 @@
1
+ """Base system config: locales, timezone, base packages."""
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from nullforge.models.system import SwapAlgo, SwapType
6
+
7
+ from .base_mold import BaseMold
8
+
9
+
10
+ def _default_packages_base() -> list[str]:
11
+ """Get the default base packages to install."""
12
+
13
+ return [
14
+ "acl",
15
+ "aha",
16
+ "apt-transport-https",
17
+ "bat",
18
+ "bind9-host",
19
+ "bison",
20
+ "btop",
21
+ "build-essential",
22
+ "dnsutils",
23
+ "duf",
24
+ "file",
25
+ "fontconfig",
26
+ "g++",
27
+ "gcc",
28
+ "git",
29
+ "gnupg",
30
+ "ifupdown2",
31
+ "ipcalc",
32
+ "iputils-ping",
33
+ "jq",
34
+ "libevent-dev",
35
+ "locales",
36
+ "mtr-tiny",
37
+ "ncat",
38
+ "ncurses-dev",
39
+ "net-tools",
40
+ "nmap",
41
+ "pkg-config",
42
+ "unzip",
43
+ "wget",
44
+ "whois",
45
+ "xsel",
46
+ "zsh",
47
+ ]
48
+
49
+
50
+ def _default_locales() -> list[str]:
51
+ """Get the default locales to generate."""
52
+
53
+ return ["en_US.UTF-8 UTF-8"]
54
+
55
+
56
+ def _default_timezone() -> str:
57
+ """Get the default timezone."""
58
+
59
+ return "Etc/UTC"
60
+
61
+
62
+ class SwapMold(BaseMold):
63
+ """System swap configuration."""
64
+
65
+ enabled: bool = Field(
66
+ default=False,
67
+ description="Whether swap should be enabled.",
68
+ )
69
+ type: SwapType = Field(
70
+ default=SwapType.ZRAM,
71
+ description="Type of swap to use (basic file or zram).",
72
+ )
73
+ algo: SwapAlgo = Field(
74
+ default=SwapAlgo.ZSTD,
75
+ description="Algorithm of ZRAM compression to use.",
76
+ )
77
+ size: str = Field(
78
+ default="60%",
79
+ description="Swap size (e.g., '4G', '512M', '50%'). For zram, usually percentage of RAM.",
80
+ )
81
+ swappiness: int = Field(
82
+ default=70,
83
+ description="Kernel swappiness value (0-100).",
84
+ ge=0,
85
+ le=100,
86
+ )
87
+
88
+
89
+ class SystemMold(BaseMold):
90
+ """Full system configuration mold."""
91
+
92
+ packages_base: list[str] = Field(
93
+ default_factory=_default_packages_base,
94
+ min_length=1,
95
+ description="System-wide base packages to install",
96
+ )
97
+ locales: list[str] = Field(
98
+ default_factory=_default_locales,
99
+ min_length=1,
100
+ description="Locales to generate",
101
+ )
102
+ timezone: str = Field(
103
+ default_factory=_default_timezone,
104
+ description="System timezone (e.g. 'UTC' or 'Europe/Amsterdam')",
105
+ )
106
+ hostname: str | None = Field(
107
+ default=None,
108
+ description="System hostname (FQDN). If None, hostname is not configured.",
109
+ )
110
+ swap: SwapMold = Field(
111
+ default_factory=SwapMold,
112
+ description="Swap configuration.",
113
+ )
114
+ ensure_ipv6: bool = Field(
115
+ default=True,
116
+ description="Ensure IPv6 kernel stack.",
117
+ )
118
+
119
+ @field_validator("hostname")
120
+ @classmethod
121
+ def _validate_hostname(cls, v: str | None) -> str | None:
122
+ """Validate hostname format."""
123
+
124
+ if v is None:
125
+ return v
126
+ if not v or len(v) > 253:
127
+ raise ValueError("hostname must be between 1 and 253 characters")
128
+ if "." not in v:
129
+ raise ValueError("hostname should be a FQDN (contain a dot)")
130
+ labels = v.split(".")
131
+ for label in labels:
132
+ if not label:
133
+ raise ValueError("hostname contains empty label (consecutive dots or leading/trailing dot)")
134
+ if len(label) > 63:
135
+ raise ValueError(f"hostname label '{label}' exceeds 63 characters")
136
+ if label.startswith("-") or label.endswith("-"):
137
+ raise ValueError(f"hostname label '{label}' cannot start or end with a hyphen")
138
+ if not all(c.isalnum() or c == "-" for c in label):
139
+ raise ValueError(f"hostname label '{label}' contains invalid characters")
140
+ return v
@@ -0,0 +1,140 @@
1
+ """Telemt (MTProto proxy) configuration mold."""
2
+
3
+ import re
4
+ from typing import ClassVar, Literal
5
+
6
+ from pydantic import Field, field_validator, model_validator
7
+
8
+ from .base_mold import BaseMold
9
+
10
+
11
+ TelemtIpPreference = Literal[4, 6]
12
+ """Address family telemt prefers when both are available (network.prefer)."""
13
+
14
+ _SECRET_RE = re.compile(r"^[0-9a-f]{32}$")
15
+ """Telemt user secrets are 16 bytes rendered as 32 lowercase hex characters."""
16
+
17
+ _USERNAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
18
+ """Usernames become TOML keys under [access.users]."""
19
+
20
+
21
+ class TelemtMold(BaseMold):
22
+ """Telemt MTProto proxy for Telegram."""
23
+
24
+ _sensitive_fields: ClassVar[tuple[str, ...]] = ("users",)
25
+
26
+ install: bool = Field(
27
+ default=False,
28
+ description="Whether to install the telemt MTProto proxy",
29
+ )
30
+ port: int = Field(
31
+ default=8443,
32
+ ge=1,
33
+ le=65535,
34
+ description="TCP port telemt listens on",
35
+ )
36
+ tls_domain: str = Field(
37
+ default="",
38
+ description="Fake-TLS / SNI masking domain used in generated ee-links (censorship.tls_domain)",
39
+ )
40
+ users: dict[str, str] = Field(
41
+ default_factory=dict,
42
+ description="Access users mapping username -> 32 hex-char secret ([access.users])",
43
+ )
44
+ max_connections: int = Field(
45
+ default=16384,
46
+ ge=1,
47
+ le=65535,
48
+ description="Maximum concurrent client connections (server.max_connections)",
49
+ )
50
+ client_handshake: int = Field(
51
+ default=30,
52
+ gt=0,
53
+ le=300,
54
+ description="Seconds for client to finish handshake (timeouts.client_handshake)",
55
+ )
56
+ fast_mode: bool = Field(
57
+ default=True,
58
+ description="Enable telemt fast mode (general.fast_mode)",
59
+ )
60
+ use_middle_proxy: bool = Field(
61
+ default=False,
62
+ description="Route via Telegram middle proxies to enable ad tags (general.use_middle_proxy)",
63
+ )
64
+ mode_classic: bool = Field(
65
+ default=False,
66
+ description="Enable classic (unobfuscated) MTProto mode (general.modes.classic)",
67
+ )
68
+ mode_secure: bool = Field(
69
+ default=False,
70
+ description="Enable secure (dd-prefixed) MTProto mode (general.modes.secure)",
71
+ )
72
+ mode_tls: bool = Field(
73
+ default=True,
74
+ description="Enable Fake-TLS (ee-prefixed) MTProto mode (general.modes.tls)",
75
+ )
76
+ ipv6: bool = Field(
77
+ default=True,
78
+ description="Allow IPv6 upstream connections (network.ipv6)",
79
+ )
80
+ prefer: TelemtIpPreference = Field(
81
+ default=6,
82
+ description="Preferred address family when both are available (network.prefer)",
83
+ )
84
+ api_enabled: bool = Field(
85
+ default=False,
86
+ description="Expose the local management/metrics API (server.api.enabled)",
87
+ )
88
+ unknown_sni_action: str | None = Field(
89
+ default=None,
90
+ description="How to treat connections with an unexpected SNI (censorship.unknown_sni_action)",
91
+ )
92
+ pq_check: bool = Field(
93
+ default=True,
94
+ description=(
95
+ "Probe tls_domain for X25519MLKEM768 (post-quantum) support at deploy time and warn when "
96
+ "it is missing, since Telegram's iOS client tends to get blocked on such domains"
97
+ ),
98
+ )
99
+ route_via_warp: bool = Field(
100
+ default=False,
101
+ description="Route Telegram-bound egress through WARP via per-uid policy routing",
102
+ )
103
+ synfix: bool = Field(
104
+ default=False,
105
+ description="Apply MEKO SYN rate-limiting fix",
106
+ )
107
+
108
+ @property
109
+ def is_active(self) -> bool:
110
+ return self.install
111
+
112
+ @field_validator("tls_domain")
113
+ @classmethod
114
+ def _strip_domain(cls, v: str) -> str:
115
+ return v.strip()
116
+
117
+ @field_validator("users")
118
+ @classmethod
119
+ def _validate_users(cls, v: dict[str, str]) -> dict[str, str]:
120
+ normalized: dict[str, str] = {}
121
+ for name, secret in v.items():
122
+ if not _USERNAME_RE.fullmatch(name):
123
+ raise ValueError(f"Invalid telemt username {name!r}: use only letters, digits, '-' and '_'")
124
+ secret = secret.strip().lower()
125
+ if not _SECRET_RE.match(secret):
126
+ raise ValueError(f"Invalid telemt secret for {name!r}: expected 32 hexadecimal characters")
127
+ normalized[name] = secret
128
+ return normalized
129
+
130
+ @model_validator(mode="after")
131
+ def _validate_enabled(self) -> "TelemtMold":
132
+ if not self.install:
133
+ return self
134
+ if not self.users:
135
+ raise ValueError("at least one entry in `users` is required when install is True")
136
+ if self.mode_tls and not self.tls_domain:
137
+ raise ValueError("`tls_domain` is required when install is True and the Fake-TLS mode is enabled")
138
+ if not (self.mode_classic or self.mode_secure or self.mode_tls):
139
+ raise ValueError("at least one of mode_classic/mode_secure/mode_tls must be enabled when install is True")
140
+ return self
nullforge/molds/tor.py ADDED
@@ -0,0 +1,28 @@
1
+ """Tor proxy configuration mold."""
2
+
3
+ from pydantic import Field
4
+
5
+ from .base_mold import BaseMold
6
+
7
+
8
+ class TorMold(BaseMold):
9
+ install: bool = Field(
10
+ default=False,
11
+ description="Whether to install Tor proxy",
12
+ )
13
+ socks_port: int = Field(
14
+ default=9050,
15
+ ge=1,
16
+ le=65535,
17
+ description="The port to use for the Tor proxy",
18
+ )
19
+ dns_port: int = Field(
20
+ default=5353,
21
+ ge=1,
22
+ le=65535,
23
+ description="The port to use for the Tor proxy DNS",
24
+ )
25
+
26
+ @property
27
+ def is_active(self) -> bool:
28
+ return self.install
@@ -0,0 +1,77 @@
1
+ """User configuration mold."""
2
+
3
+ import re
4
+ from typing import ClassVar
5
+
6
+ from pydantic import Field, field_validator
7
+
8
+ from nullforge.models.users import Shell
9
+
10
+ from .base_mold import BaseMold
11
+
12
+
13
+ class UserMold(BaseMold):
14
+ _sensitive_fields: ClassVar[tuple[str, ...]] = ("password",)
15
+
16
+ manage: bool = Field(
17
+ default=True,
18
+ description="Whether to manage the user",
19
+ )
20
+ name: str = Field(
21
+ default="core",
22
+ description="The username for the user",
23
+ )
24
+ password: str | None = Field(
25
+ default=None,
26
+ description="The password for the user",
27
+ )
28
+ sudo: bool = Field(
29
+ default=True,
30
+ description="Whether to add the user to the sudo group",
31
+ )
32
+ shell: Shell = Field(
33
+ default=Shell.ZSH,
34
+ description="The shell for the user to use ",
35
+ )
36
+ copy_root_keys: bool = Field(
37
+ default=True,
38
+ description="Whether to copy the root user's SSH keys to the user",
39
+ )
40
+ fetch_key_from_github: str | None = Field(
41
+ default=None,
42
+ description="GitHub username whose public SSH keys to import",
43
+ )
44
+ set_root_shell_like_user: bool = Field(
45
+ default=True,
46
+ description="Whether to set the root user's shell to the user's shell",
47
+ )
48
+
49
+ @property
50
+ def is_active(self) -> bool:
51
+ return self.manage
52
+
53
+ @field_validator("name", mode="before")
54
+ @classmethod
55
+ def _validate_name(cls, value: str) -> str:
56
+ if not value:
57
+ raise ValueError("Name cannot be empty")
58
+
59
+ if value.startswith("-"):
60
+ raise ValueError("Name cannot start with a hyphen")
61
+
62
+ if not re.match(r"^[A-Za-z0-9._-]+$", value):
63
+ raise ValueError("Name must only contain characters from the portable filename character set")
64
+
65
+ return value
66
+
67
+ @field_validator("fetch_key_from_github")
68
+ @classmethod
69
+ def _validate_fetch_key_from_github(cls, value: str | None) -> str | None:
70
+ if value is not None and not re.match(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$", value):
71
+ raise ValueError(f"Invalid GitHub username: {value!r}")
72
+
73
+ return value
74
+
75
+ @property
76
+ def shell_path(self) -> str:
77
+ return self.shell.value