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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Telemt MTProto proxy deployment module."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pyinfra import logger
|
|
5
|
+
from pyinfra.api.operation import OperationMeta
|
|
6
|
+
from pyinfra.context import host
|
|
7
|
+
from pyinfra.operations import files, systemd
|
|
8
|
+
from pyinfra.operations.util import any_changed
|
|
9
|
+
|
|
10
|
+
from nullforge.molds import FeaturesMold, TelemtMold, WarpMold
|
|
11
|
+
from nullforge.smithy.install import install_release_binary
|
|
12
|
+
from nullforge.smithy.packages import get_pm
|
|
13
|
+
from nullforge.smithy.service import ensure_service_user
|
|
14
|
+
from nullforge.smithy.sni import (
|
|
15
|
+
GROUP_X25519,
|
|
16
|
+
GROUP_X25519MLKEM768,
|
|
17
|
+
KeyExchangeReport,
|
|
18
|
+
group_name,
|
|
19
|
+
inspect_group,
|
|
20
|
+
)
|
|
21
|
+
from nullforge.smithy.versions import get_versions, is_pinned_version_installed
|
|
22
|
+
from nullforge.templates import BLOCK_TRIM_ENV, get_template_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
TELEMT_USER = "telemt"
|
|
26
|
+
TELEMT_GROUP = "telemt"
|
|
27
|
+
CONFIG_DIR = "/etc/telemt"
|
|
28
|
+
CONFIG_PATH = f"{CONFIG_DIR}/telemt.toml"
|
|
29
|
+
BINARY_PATH = "/usr/local/bin/telemt"
|
|
30
|
+
TELEPROXY_SCRIPT = "/usr/local/sbin/teleproxy-warp.sh"
|
|
31
|
+
SYNFIX_SCRIPT = "/usr/local/sbin/telemt-synfix.sh"
|
|
32
|
+
SERVICE_NAME = "telemt"
|
|
33
|
+
SERVICE_UNIT = f"/etc/systemd/system/{SERVICE_NAME}.service"
|
|
34
|
+
LOG = logger
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def deploy_telemt() -> None:
|
|
38
|
+
"""Deploy the telemt MTProto proxy."""
|
|
39
|
+
|
|
40
|
+
features: FeaturesMold = host.data.features
|
|
41
|
+
telemt_opts: TelemtMold = features.telemt
|
|
42
|
+
warp_opts: WarpMold = features.warp
|
|
43
|
+
|
|
44
|
+
warp_active = warp_opts.is_active and telemt_opts.route_via_warp
|
|
45
|
+
|
|
46
|
+
_vet_tls_domain(telemt_opts)
|
|
47
|
+
|
|
48
|
+
ensure_service_user(TELEMT_USER, TELEMT_GROUP, CONFIG_DIR)
|
|
49
|
+
|
|
50
|
+
binary_op = _install_telemt()
|
|
51
|
+
config_op = _deploy_config(telemt_opts, warp_active, warp_opts.iface)
|
|
52
|
+
|
|
53
|
+
teleproxy_op = _deploy_teleproxy_script() if warp_active else None
|
|
54
|
+
synfix_op = _deploy_synfix_script() if telemt_opts.synfix else None
|
|
55
|
+
|
|
56
|
+
unit_op = _deploy_service_unit(
|
|
57
|
+
telemt_opts,
|
|
58
|
+
warp_active=warp_active,
|
|
59
|
+
warp_service=warp_opts.engine.systemd_service_name,
|
|
60
|
+
warp_iface=warp_opts.iface,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
systemd.daemon_reload(
|
|
64
|
+
name="Reload systemd daemon for telemt",
|
|
65
|
+
_sudo=True,
|
|
66
|
+
_if=unit_op.did_change,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# The helper scripts run from telemt's ExecStartPre/ExecStopPost, so a change
|
|
70
|
+
# to either takes effect on the next telemt (re)start.
|
|
71
|
+
restart_ops = [op for op in (binary_op, config_op, teleproxy_op, synfix_op, unit_op) if op]
|
|
72
|
+
systemd.service(
|
|
73
|
+
name="Restart telemt on change",
|
|
74
|
+
service=SERVICE_NAME,
|
|
75
|
+
restarted=True,
|
|
76
|
+
_sudo=True,
|
|
77
|
+
_if=any_changed(*restart_ops),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
systemd.service(
|
|
81
|
+
name="Ensure telemt is running and enabled",
|
|
82
|
+
service=SERVICE_NAME,
|
|
83
|
+
running=True,
|
|
84
|
+
enabled=True,
|
|
85
|
+
_sudo=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _vet_tls_domain(opts: TelemtMold) -> None:
|
|
90
|
+
"""Report whether the Fake-TLS masking domain looks likely to get the proxy blocked.
|
|
91
|
+
|
|
92
|
+
Telegram's iOS client expects masking domain to negotiate X25519MLKEM768 hybrid
|
|
93
|
+
post-quantum key exchange; domains that instead fall back to X25519 are observed
|
|
94
|
+
to get proxies blocked on iOS.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
if not (opts.pq_check and opts.mode_tls and opts.tls_domain):
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
domain = opts.tls_domain
|
|
101
|
+
report = inspect_group(domain, GROUP_X25519MLKEM768)
|
|
102
|
+
verdict = _tls_domain_verdict(domain, report)
|
|
103
|
+
|
|
104
|
+
if report.conclusive and report.supported:
|
|
105
|
+
LOG.info(click.style(verdict, fg="green"))
|
|
106
|
+
else:
|
|
107
|
+
LOG.warning(verdict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tls_domain_verdict(domain: str, report: KeyExchangeReport) -> str:
|
|
111
|
+
"""Phrase a key-exchange report as advice about a Fake-TLS masking domain."""
|
|
112
|
+
|
|
113
|
+
wanted = group_name(GROUP_X25519MLKEM768)
|
|
114
|
+
advice = f"pick a masking domain that offers {wanted}"
|
|
115
|
+
|
|
116
|
+
if not report.conclusive:
|
|
117
|
+
return f"telemt tls_domain '{domain}' could not be vetted for {wanted} support: {report.error}"
|
|
118
|
+
if report.supported:
|
|
119
|
+
return f"telemt tls_domain '{domain}' negotiates {wanted}"
|
|
120
|
+
if not report.supports_tls13:
|
|
121
|
+
return (
|
|
122
|
+
f"telemt tls_domain '{domain}' does not support TLS 1.3, so it cannot offer {wanted}: "
|
|
123
|
+
f"Telegram's iOS client is likely to be blocked on this domain, {advice}"
|
|
124
|
+
)
|
|
125
|
+
if report.fallback == GROUP_X25519:
|
|
126
|
+
return (
|
|
127
|
+
f"telemt tls_domain '{domain}' does not support {wanted} and falls back to "
|
|
128
|
+
f"{group_name(GROUP_X25519)}: Telegram's iOS client is likely to be blocked on this "
|
|
129
|
+
f"domain, {advice}"
|
|
130
|
+
)
|
|
131
|
+
return (
|
|
132
|
+
f"telemt tls_domain '{domain}' does not support {wanted} and negotiates "
|
|
133
|
+
f"{group_name(report.fallback)}: iOS clients may be unreliable, prefer to {advice}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _install_telemt() -> OperationMeta | None:
|
|
138
|
+
"""Install the pinned telemt binary from its glibc release tarball."""
|
|
139
|
+
|
|
140
|
+
if is_pinned_version_installed("telemt", BINARY_PATH):
|
|
141
|
+
host.noop("telemt binary is already at the pinned version")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
return install_release_binary(
|
|
145
|
+
name="Extract and install telemt binary",
|
|
146
|
+
url=get_versions().telemt_tar(),
|
|
147
|
+
dest=BINARY_PATH,
|
|
148
|
+
binary_name="telemt",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _deploy_config(opts: TelemtMold, warp_active: bool, warp_iface: str) -> OperationMeta:
|
|
153
|
+
"""Render the telemt.toml configuration."""
|
|
154
|
+
|
|
155
|
+
return files.template(
|
|
156
|
+
name="Deploy telemt configuration",
|
|
157
|
+
src=get_template_path("telemt/telemt.toml.j2"),
|
|
158
|
+
dest=CONFIG_PATH,
|
|
159
|
+
user=TELEMT_USER,
|
|
160
|
+
group=TELEMT_GROUP,
|
|
161
|
+
mode="0640",
|
|
162
|
+
jinja_env_kwargs=BLOCK_TRIM_ENV,
|
|
163
|
+
FAST_MODE=opts.fast_mode,
|
|
164
|
+
USE_MIDDLE_PROXY=opts.use_middle_proxy,
|
|
165
|
+
MODE_CLASSIC=opts.mode_classic,
|
|
166
|
+
MODE_SECURE=opts.mode_secure,
|
|
167
|
+
MODE_TLS=opts.mode_tls,
|
|
168
|
+
MAX_CONNECTIONS=opts.max_connections,
|
|
169
|
+
CLIENT_HANDSHAKE=opts.client_handshake,
|
|
170
|
+
PORT=opts.port,
|
|
171
|
+
API_ENABLED=opts.api_enabled,
|
|
172
|
+
IPV6=opts.ipv6,
|
|
173
|
+
PREFER=opts.prefer,
|
|
174
|
+
TLS_DOMAIN=opts.tls_domain,
|
|
175
|
+
UNKNOWN_SNI_ACTION=opts.unknown_sni_action,
|
|
176
|
+
USERS=opts.users,
|
|
177
|
+
ROUTE_VIA_WARP=warp_active,
|
|
178
|
+
WARP_IFACE=warp_iface,
|
|
179
|
+
_sudo=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _deploy_teleproxy_script() -> OperationMeta:
|
|
184
|
+
"""Deploy the per-uid WARP policy-routing helper (invoked from telemt.service)."""
|
|
185
|
+
|
|
186
|
+
return files.put(
|
|
187
|
+
name="Deploy teleproxy WARP routing script",
|
|
188
|
+
src=get_template_path("scripts/teleproxy-warp.sh"),
|
|
189
|
+
dest=TELEPROXY_SCRIPT,
|
|
190
|
+
user="root",
|
|
191
|
+
group="root",
|
|
192
|
+
mode="0755",
|
|
193
|
+
_sudo=True,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _deploy_synfix_script() -> OperationMeta:
|
|
198
|
+
"""Deploy the MEKO SYN rate-limiting helper (invoked from telemt.service).
|
|
199
|
+
|
|
200
|
+
The script re-applies the iptables chain on every telemt (re)start and uses
|
|
201
|
+
RETURN semantics inserted ahead of the firewall, so it coexists with
|
|
202
|
+
UFW/firewalld rather than replacing their rules.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
get_pm().install(
|
|
206
|
+
name="Install iptables for telemt SYN fix",
|
|
207
|
+
packages=["iptables"],
|
|
208
|
+
_sudo=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return files.put(
|
|
212
|
+
name="Deploy telemt SYN-fix script",
|
|
213
|
+
src=get_template_path("scripts/telemt-synfix.sh"),
|
|
214
|
+
dest=SYNFIX_SCRIPT,
|
|
215
|
+
user="root",
|
|
216
|
+
group="root",
|
|
217
|
+
mode="0755",
|
|
218
|
+
_sudo=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _deploy_service_unit(
|
|
223
|
+
opts: TelemtMold,
|
|
224
|
+
*,
|
|
225
|
+
warp_active: bool,
|
|
226
|
+
warp_service: str,
|
|
227
|
+
warp_iface: str,
|
|
228
|
+
) -> OperationMeta:
|
|
229
|
+
"""Render the telemt systemd unit."""
|
|
230
|
+
|
|
231
|
+
return files.template(
|
|
232
|
+
name="Deploy telemt systemd service",
|
|
233
|
+
src=get_template_path("systemd/telemt.service.j2"),
|
|
234
|
+
dest=SERVICE_UNIT,
|
|
235
|
+
mode="0644",
|
|
236
|
+
jinja_env_kwargs=BLOCK_TRIM_ENV,
|
|
237
|
+
USER=TELEMT_USER,
|
|
238
|
+
GROUP=TELEMT_GROUP,
|
|
239
|
+
BINARY_PATH=BINARY_PATH,
|
|
240
|
+
CONFIG_PATH=CONFIG_PATH,
|
|
241
|
+
PORT=opts.port,
|
|
242
|
+
WARP_ENABLED=warp_active,
|
|
243
|
+
WARP_SERVICE=warp_service,
|
|
244
|
+
WARP_IFACE=warp_iface,
|
|
245
|
+
TELEPROXY_SCRIPT=TELEPROXY_SCRIPT,
|
|
246
|
+
SYNFIX_ENABLED=opts.synfix,
|
|
247
|
+
SYNFIX_SCRIPT=SYNFIX_SCRIPT,
|
|
248
|
+
_sudo=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
deploy_telemt()
|
nullforge/runes/tor.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tor proxy deployment module."""
|
|
2
|
+
|
|
3
|
+
from pyinfra.context import host
|
|
4
|
+
from pyinfra.operations import files, systemd
|
|
5
|
+
|
|
6
|
+
from nullforge.molds import FeaturesMold, TorMold
|
|
7
|
+
from nullforge.smithy.packages import get_pm
|
|
8
|
+
from nullforge.templates import get_template_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def deploy_tor() -> None:
|
|
12
|
+
"""Deploy Tor proxy configuration."""
|
|
13
|
+
|
|
14
|
+
features: FeaturesMold = host.data.features
|
|
15
|
+
tor_opts: TorMold = features.tor
|
|
16
|
+
|
|
17
|
+
_install_tor(tor_opts)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _install_tor(opts: TorMold) -> None:
|
|
21
|
+
"""Install Tor proxy."""
|
|
22
|
+
|
|
23
|
+
pm = get_pm()
|
|
24
|
+
pm.install(
|
|
25
|
+
name="Install Tor package",
|
|
26
|
+
packages=["tor"],
|
|
27
|
+
_sudo=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
config_template = files.template(
|
|
31
|
+
name="Deploy Tor proxy configuration",
|
|
32
|
+
src=get_template_path("tor/torrc.j2"),
|
|
33
|
+
dest="/etc/tor/torrc",
|
|
34
|
+
mode="0644",
|
|
35
|
+
SOCKS_PORT=opts.socks_port,
|
|
36
|
+
DNS_PORT=opts.dns_port,
|
|
37
|
+
_sudo=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
systemd.service(
|
|
41
|
+
name="Restart Tor on change",
|
|
42
|
+
service="tor",
|
|
43
|
+
restarted=True,
|
|
44
|
+
_sudo=True,
|
|
45
|
+
_if=config_template.did_change,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
systemd.service(
|
|
49
|
+
name="Ensure Tor is running and enabled",
|
|
50
|
+
service="tor",
|
|
51
|
+
running=True,
|
|
52
|
+
enabled=True,
|
|
53
|
+
_sudo=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
deploy_tor()
|
nullforge/runes/users.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""User management deployment module."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import shlex
|
|
5
|
+
|
|
6
|
+
from pyinfra.context import host
|
|
7
|
+
from pyinfra.facts.files import FileContents
|
|
8
|
+
from pyinfra.facts.server import Home
|
|
9
|
+
from pyinfra.operations import files, server
|
|
10
|
+
|
|
11
|
+
from nullforge.molds import FeaturesMold, UserMold
|
|
12
|
+
from nullforge.smithy.http import fetch_github_keys
|
|
13
|
+
from nullforge.smithy.packages import get_pm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def deploy_user_management() -> None:
|
|
17
|
+
"""Deploy user management configuration."""
|
|
18
|
+
|
|
19
|
+
features: FeaturesMold = host.data.features
|
|
20
|
+
user_opts: UserMold = features.users
|
|
21
|
+
|
|
22
|
+
if user_opts.sudo:
|
|
23
|
+
sudo_group = "sudo" if get_pm().is_debian_based else "wheel"
|
|
24
|
+
groups = [sudo_group]
|
|
25
|
+
operation_name = f"Create user {user_opts.name} with sudo access"
|
|
26
|
+
else:
|
|
27
|
+
groups = []
|
|
28
|
+
operation_name = f"Create user {user_opts.name}"
|
|
29
|
+
|
|
30
|
+
server.user(
|
|
31
|
+
name=operation_name,
|
|
32
|
+
user=user_opts.name,
|
|
33
|
+
shell=user_opts.shell_path,
|
|
34
|
+
groups=groups,
|
|
35
|
+
append=True,
|
|
36
|
+
create_home=True,
|
|
37
|
+
_sudo=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if user_opts.password:
|
|
41
|
+
_set_user_password(user_opts, user_opts.password)
|
|
42
|
+
elif user_opts.sudo:
|
|
43
|
+
_configure_passwordless_sudo(user_opts)
|
|
44
|
+
|
|
45
|
+
if user_opts.copy_root_keys or user_opts.fetch_key_from_github:
|
|
46
|
+
_install_ssh_keys(user_opts)
|
|
47
|
+
|
|
48
|
+
if user_opts.set_root_shell_like_user:
|
|
49
|
+
server.user(
|
|
50
|
+
name="Set root shell to user's shell",
|
|
51
|
+
user="root",
|
|
52
|
+
shell=user_opts.shell_path,
|
|
53
|
+
_sudo=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_user_password(opts: UserMold, password: str) -> None:
|
|
58
|
+
"""Set user password with protection against plaintext leakage."""
|
|
59
|
+
|
|
60
|
+
creds_path = f"/root/.nullforge-chpasswd-{opts.name}"
|
|
61
|
+
quoted_path = shlex.quote(creds_path)
|
|
62
|
+
|
|
63
|
+
files.put(
|
|
64
|
+
name=f"Stage credentials for user {opts.name}",
|
|
65
|
+
src=io.StringIO(f"{opts.name}:{password}\n"),
|
|
66
|
+
dest=creds_path,
|
|
67
|
+
mode="0600",
|
|
68
|
+
user="root",
|
|
69
|
+
group="root",
|
|
70
|
+
_sudo=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
server.shell(
|
|
74
|
+
name=f"Set password for user {opts.name}",
|
|
75
|
+
commands=[f"chpasswd < {quoted_path}; rc=$?; rm -f {quoted_path}; exit $rc"],
|
|
76
|
+
_sudo=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _configure_passwordless_sudo(opts: UserMold) -> None:
|
|
81
|
+
"""Configure passwordless sudo for user when no password is set."""
|
|
82
|
+
|
|
83
|
+
username = opts.name
|
|
84
|
+
sudoers_file = f"/etc/sudoers.d/{username}"
|
|
85
|
+
sudoers_line = f"{username} ALL=(ALL) NOPASSWD:ALL"
|
|
86
|
+
|
|
87
|
+
files.put(
|
|
88
|
+
name=f"Configure passwordless sudo for {username}",
|
|
89
|
+
src=io.StringIO(f"{sudoers_line}\n"),
|
|
90
|
+
dest=sudoers_file,
|
|
91
|
+
mode="0440",
|
|
92
|
+
user="root",
|
|
93
|
+
group="root",
|
|
94
|
+
_sudo=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _dedup_keys(keys: list[str]) -> list[str]:
|
|
99
|
+
"""Deduplicate keys and remove blank lines and comments."""
|
|
100
|
+
|
|
101
|
+
valid = (key.strip() for key in keys if key.strip() and not key.strip().startswith("#"))
|
|
102
|
+
return list(dict.fromkeys(valid))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _install_ssh_keys(opts: UserMold) -> None:
|
|
106
|
+
"""Install SSH keys for the user from root's authorized_keys and/or GitHub."""
|
|
107
|
+
|
|
108
|
+
username = opts.name
|
|
109
|
+
new_user_home = host.get_fact(Home, user=username)
|
|
110
|
+
|
|
111
|
+
keys: list[str] = []
|
|
112
|
+
|
|
113
|
+
if opts.copy_root_keys:
|
|
114
|
+
current_user_home = host.get_fact(Home)
|
|
115
|
+
keys.extend(host.get_fact(FileContents, f"{current_user_home}/.ssh/authorized_keys") or [])
|
|
116
|
+
|
|
117
|
+
if opts.fetch_key_from_github:
|
|
118
|
+
keys.extend(fetch_github_keys(opts.fetch_key_from_github))
|
|
119
|
+
|
|
120
|
+
valid_keys = _dedup_keys(keys)
|
|
121
|
+
|
|
122
|
+
if valid_keys:
|
|
123
|
+
files.directory(
|
|
124
|
+
name=f"Ensure SSH directory for {username}",
|
|
125
|
+
path=f"{new_user_home}/.ssh",
|
|
126
|
+
user=username,
|
|
127
|
+
group=username,
|
|
128
|
+
mode="0700",
|
|
129
|
+
_sudo=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
files.directory(
|
|
133
|
+
name=f"Ensure SSH sockets directory for {username}",
|
|
134
|
+
path=f"{new_user_home}/.ssh/sockets",
|
|
135
|
+
user=username,
|
|
136
|
+
group=username,
|
|
137
|
+
mode="0700",
|
|
138
|
+
_sudo=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
server.user_authorized_keys(
|
|
142
|
+
name=f"Add SSH keys for {username}",
|
|
143
|
+
user=username,
|
|
144
|
+
group=username,
|
|
145
|
+
public_keys=valid_keys,
|
|
146
|
+
_sudo=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
files.file(
|
|
150
|
+
name=f"Set SSH key permissions for {username}",
|
|
151
|
+
path=f"{new_user_home}/.ssh/authorized_keys",
|
|
152
|
+
user=username,
|
|
153
|
+
group=username,
|
|
154
|
+
mode="0600",
|
|
155
|
+
_sudo=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
deploy_user_management()
|