cryptnode 0.1.1__1-py3-none-win_amd64.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.
cryptnode/ubuntu.py ADDED
@@ -0,0 +1,144 @@
1
+ """One systemd instance per portal on Ubuntu; no root-resident Host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import pwd
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from typing import Dict
14
+
15
+ from .models import NodeError, Profile
16
+ from .paths import profile_dir
17
+
18
+
19
+ UNIT_DIR = Path("/etc/systemd/system")
20
+
21
+
22
+ def _unit_name(profile: Profile) -> str:
23
+ return "cryptnode@%d.service" % profile.portal_id
24
+
25
+
26
+ def _quote(value: str) -> str:
27
+ if any(character in value for character in "\r\n\x00"):
28
+ raise NodeError("unit_path", "systemd path contains a control character")
29
+ return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
30
+
31
+
32
+ def _working_directory(value: str) -> str:
33
+ if not value.startswith("/") or any(character in value for character in "\r\n\x00"):
34
+ raise NodeError("unit_path", "systemd working directory must be an absolute path")
35
+ return value.replace("\\", "\\\\").replace("%", "%%")
36
+
37
+
38
+ def _run(*args: str, allow_failure: bool = False) -> str:
39
+ result = subprocess.run(["systemctl", *args], capture_output=True, text=True,
40
+ timeout=30, check=False)
41
+ if result.returncode != 0 and not allow_failure:
42
+ raise NodeError("systemd_failed", "systemd operation failed: %s" % args[0])
43
+ return result.stdout
44
+
45
+
46
+ def status(profile: Profile) -> Dict[str, str]:
47
+ output = _run("show", _unit_name(profile),
48
+ "--property=LoadState,ActiveState,SubState,UnitFileState,MainPID", allow_failure=True)
49
+ row = dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
50
+ if row.get("LoadState") != "loaded":
51
+ return {"exists": "false", "active": "false", "enabled": "false", "pid": "0"}
52
+ return {"exists": "true", "active": str(row.get("ActiveState") == "active").lower(),
53
+ "enabled": str(row.get("UnitFileState") == "enabled").lower(),
54
+ "pid": row.get("MainPID", "0"), "substate": row.get("SubState", "")}
55
+
56
+
57
+ def reset_failed(profile: Profile) -> None:
58
+ """Let one explicit CLI start recover without changing automatic retry limits."""
59
+ _run("reset-failed", _unit_name(profile))
60
+
61
+
62
+ def _runtime_user() -> str:
63
+ name = os.environ.get("SUDO_USER")
64
+ if not name or name == "root":
65
+ raise NodeError("runtime_user", "sudo must preserve the trusted deployment administrator")
66
+ account = pwd.getpwnam(name)
67
+ if account.pw_uid == 0 or not name.replace("-", "").replace("_", "").isalnum():
68
+ raise NodeError("runtime_user", "invalid runtime administrator")
69
+ return name
70
+
71
+
72
+ def _unit_text(root: Path, profile: Profile) -> bytes:
73
+ user = _runtime_user()
74
+ directory = profile_dir(root, profile.portal_id)
75
+ python = str(Path(sys.executable).absolute())
76
+ command = "%s -m cryptnode.host --portal-id %d --install-dir %s" % (
77
+ _quote(python), profile.portal_id, _quote(str(root)))
78
+ lines = [
79
+ "[Unit]", "Description=CryptNode portal %d" % profile.portal_id,
80
+ "Wants=network-online.target", "After=network-online.target",
81
+ "StartLimitIntervalSec=600", "StartLimitBurst=3", "",
82
+ "[Service]", "Type=simple", "User=%s" % user,
83
+ "WorkingDirectory=%s" % _working_directory(str(directory)), "UMask=0077",
84
+ "ExecStartPre=%s --validate" % command, "ExecStart=%s" % command,
85
+ "Restart=on-failure", "RestartSec=30s", "KillMode=control-group", "TimeoutStopSec=20s",
86
+ "NoNewPrivileges=true", "ProtectSystem=strict", "ReadWritePaths=%s" % _quote(str(directory)),
87
+ "", "[Install]", "WantedBy=multi-user.target", "",
88
+ ]
89
+ return "\n".join(lines).encode("utf-8")
90
+
91
+
92
+ def install(root: Path, profile: Profile) -> None:
93
+ path = UNIT_DIR / _unit_name(profile)
94
+ if path.is_symlink():
95
+ raise NodeError("systemd_unit", "systemd unit path is a symbolic link")
96
+ descriptor, temporary = tempfile.mkstemp(prefix=".cryptnode-", dir=str(UNIT_DIR))
97
+ try:
98
+ with os.fdopen(descriptor, "wb") as stream:
99
+ stream.write(_unit_text(root, profile))
100
+ stream.flush()
101
+ os.fsync(stream.fileno())
102
+ os.chmod(temporary, 0o644)
103
+ os.replace(temporary, path)
104
+ finally:
105
+ if os.path.exists(temporary):
106
+ os.unlink(temporary)
107
+ _run("daemon-reload")
108
+ _run("enable", _unit_name(profile))
109
+ if status(profile)["enabled"] != "true":
110
+ raise NodeError("systemd_readback", "systemd did not enable the portal instance")
111
+
112
+
113
+ def perform(root: Path, profile: Profile, action: str) -> Dict[str, str]:
114
+ if action not in ("start", "stop", "restart", "enable", "disable"):
115
+ raise ValueError("invalid systemd action")
116
+ before = status(profile)
117
+ if before["exists"] != "true":
118
+ raise NodeError("unit_missing", "CryptNode systemd instance is missing")
119
+ directory = profile_dir(root, profile.portal_id)
120
+ if action in ("start", "restart"):
121
+ (directory / "stop-request").unlink(missing_ok=True)
122
+ _run(action, _unit_name(profile))
123
+ expected = "enabled" if action in ("enable", "disable") else "active"
124
+ wanted = "false" if action in ("stop", "disable") else "true"
125
+ for _ in range(40):
126
+ result = status(profile)
127
+ if result[expected] == wanted:
128
+ return result
129
+ time.sleep(0.25)
130
+ raise NodeError("systemd_readback", "systemd state did not reach the requested value")
131
+
132
+
133
+ def remove(root: Path, profile: Profile) -> None:
134
+ name = _unit_name(profile)
135
+ if status(profile)["exists"] == "true":
136
+ perform(root, profile, "stop")
137
+ perform(root, profile, "disable")
138
+ path = UNIT_DIR / name
139
+ if path.is_symlink():
140
+ raise NodeError("systemd_unit", "systemd unit path is a symbolic link")
141
+ path.unlink(missing_ok=True)
142
+ _run("daemon-reload")
143
+ if status(profile)["exists"] != "false":
144
+ raise NodeError("systemd_readback", "systemd instance remains registered")
cryptnode/windows.py ADDED
@@ -0,0 +1,313 @@
1
+ """Protected, single-instance Task Scheduler registration for one portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from typing import Dict, Optional, Sequence
13
+ from xml.etree import ElementTree
14
+ from xml.sax.saxutils import escape
15
+
16
+ from .models import NodeError, Profile
17
+ from .paths import profile_dir, read_profile
18
+ from .security import atomic_write
19
+
20
+
21
+ _NS = "http://schemas.microsoft.com/windows/2004/02/mit/task"
22
+ _LOCAL_RESTART_DELAYS = (30.0, 60.0, 120.0)
23
+
24
+
25
+ def _name(profile: Profile) -> str:
26
+ return "\\CryptNode\\portal-%d" % profile.portal_id
27
+
28
+
29
+ def _run(*arguments: str, allow_failure: bool = False) -> subprocess.CompletedProcess:
30
+ result = subprocess.run(["schtasks.exe", *arguments], capture_output=True, text=True,
31
+ timeout=30, check=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
32
+ if result.returncode != 0 and not allow_failure:
33
+ raise NodeError("task_failed", "Task Scheduler operation failed")
34
+ return result
35
+
36
+
37
+ def _task_xml(root: Path, profile: Profile) -> bytes:
38
+ python = Path(sys.executable).absolute()
39
+ arguments = subprocess.list2cmdline(["-m", "cryptnode.windows", "--portal-id",
40
+ str(profile.portal_id), "--install-dir", str(root)])
41
+ command = escape(str(python))
42
+ args = escape(arguments)
43
+ description = escape("CryptNode portal %d (%s)" % (profile.portal_id, profile.portal_name))
44
+ document = '''<Task version="1.4" xmlns="%s">
45
+ <RegistrationInfo><Description>%s</Description></RegistrationInfo>
46
+ <Triggers><BootTrigger><Enabled>true</Enabled><Delay>PT45S</Delay></BootTrigger></Triggers>
47
+ <Principals><Principal id="CryptNode"><UserId>S-1-5-18</UserId>
48
+ <RunLevel>HighestAvailable</RunLevel>
49
+ </Principal></Principals>
50
+ <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
51
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
52
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
53
+ <AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable>
54
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
55
+ <IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>
56
+ <AllowStartOnDemand>true</AllowStartOnDemand><RunOnlyIfIdle>false</RunOnlyIfIdle>
57
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
58
+ <RestartOnFailure><Interval>PT2M</Interval><Count>3</Count></RestartOnFailure>
59
+ <Enabled>true</Enabled>
60
+ </Settings>
61
+ <Actions Context="CryptNode"><Exec><Command>%s</Command><Arguments>%s</Arguments></Exec></Actions>
62
+ </Task>
63
+ ''' % (_NS, description, command, args)
64
+ return document.encode("utf-8")
65
+
66
+
67
+ def _query_xml(profile: Profile) -> str:
68
+ result = _run("/query", "/tn", _name(profile), "/xml", allow_failure=True)
69
+ if result.returncode != 0:
70
+ return ""
71
+ return result.stdout
72
+
73
+
74
+ def _verify_definition(root: Path, profile: Profile) -> bool:
75
+ raw = _query_xml(profile)
76
+ if not raw:
77
+ return False
78
+ try:
79
+ tree = ElementTree.fromstring(raw)
80
+ ns = {"t": _NS}
81
+ get = lambda path: tree.findtext(path, namespaces=ns)
82
+ wanted = ElementTree.fromstring(_task_xml(root, profile))
83
+ wanted_get = lambda path: wanted.findtext(path, namespaces=ns)
84
+ fields = ("t:RegistrationInfo/t:Description", "t:Principals/t:Principal/t:UserId",
85
+ "t:Principals/t:Principal/t:LogonType", "t:Principals/t:Principal/t:RunLevel",
86
+ "t:Triggers/t:BootTrigger/t:Enabled", "t:Triggers/t:BootTrigger/t:Delay",
87
+ "t:Settings/t:MultipleInstancesPolicy",
88
+ "t:Settings/t:DisallowStartIfOnBatteries",
89
+ "t:Settings/t:StopIfGoingOnBatteries", "t:Settings/t:AllowHardTerminate",
90
+ "t:Settings/t:StartWhenAvailable", "t:Settings/t:RunOnlyIfNetworkAvailable",
91
+ "t:Settings/t:IdleSettings/t:StopOnIdleEnd",
92
+ "t:Settings/t:IdleSettings/t:RestartOnIdle",
93
+ "t:Settings/t:AllowStartOnDemand", "t:Settings/t:RunOnlyIfIdle",
94
+ "t:Settings/t:ExecutionTimeLimit", "t:Settings/t:Enabled",
95
+ "t:Settings/t:RestartOnFailure/t:Interval",
96
+ "t:Settings/t:RestartOnFailure/t:Count",
97
+ "t:Actions/t:Exec/t:Command", "t:Actions/t:Exec/t:Arguments")
98
+ omitted_defaults = {
99
+ "t:Triggers/t:BootTrigger/t:Enabled": "true",
100
+ "t:Settings/t:AllowHardTerminate": "true",
101
+ "t:Settings/t:RunOnlyIfNetworkAvailable": "false",
102
+ "t:Settings/t:IdleSettings/t:RestartOnIdle": "false",
103
+ "t:Settings/t:AllowStartOnDemand": "true",
104
+ "t:Settings/t:RunOnlyIfIdle": "false",
105
+ "t:Settings/t:Enabled": "true",
106
+ }
107
+ for field in fields:
108
+ actual = get(field)
109
+ if actual is None:
110
+ actual = omitted_defaults.get(field)
111
+ if actual != wanted_get(field):
112
+ return False
113
+ return True
114
+ except ElementTree.ParseError:
115
+ return False
116
+
117
+
118
+ def _query_state(profile: Profile) -> Dict[str, str]:
119
+ name = "portal-%d" % profile.portal_id
120
+ script = r'''$ErrorActionPreference = 'Stop'
121
+ try {
122
+ $service = New-Object -ComObject Schedule.Service
123
+ $service.Connect()
124
+ $task = $service.GetFolder('\CryptNode').GetTask('%s')
125
+ [pscustomobject]@{query='ok'; state=[int]$task.State; enabled=[bool]$task.Enabled} |
126
+ ConvertTo-Json -Compress
127
+ } catch {
128
+ $exception = $_.Exception
129
+ while ($null -ne $exception.InnerException) { $exception = $exception.InnerException }
130
+ [long]$hresult = $exception.HResult
131
+ if ($hresult -lt 0) { $hresult += 4294967296 }
132
+ $query = switch ($hresult) {
133
+ 2147942402 { 'missing'; break }
134
+ 2147942403 { 'missing'; break }
135
+ 2147942405 { 'access_limited'; break }
136
+ default { 'unknown' }
137
+ }
138
+ [pscustomobject]@{query=$query; hresult=$hresult} | ConvertTo-Json -Compress
139
+ }''' % name
140
+ result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
141
+ capture_output=True, text=True, timeout=30, check=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
142
+ if result.returncode != 0:
143
+ raise NodeError("task_readback", "Task Scheduler state could not be read")
144
+ try:
145
+ value = json.loads(result.stdout)
146
+ query = value["query"]
147
+ if query == "missing":
148
+ return {"exists": "false", "active": "false", "enabled": "false"}
149
+ if query == "access_limited":
150
+ return {"exists": "access_limited", "active": "access_limited",
151
+ "enabled": "access_limited", "state": "AccessLimited"}
152
+ if query == "unknown":
153
+ return {"exists": "unknown", "active": "unknown",
154
+ "enabled": "unknown", "state": "Unknown"}
155
+ states = ("Unknown", "Disabled", "Queued", "Ready", "Running")
156
+ state = value["state"]
157
+ enabled = value["enabled"]
158
+ if (query != "ok" or type(state) is not int or not 0 <= state < len(states)
159
+ or type(enabled) is not bool):
160
+ raise ValueError("task state")
161
+ return {"exists": "true", "active": str(state == 4).lower(),
162
+ "enabled": str(enabled).lower(), "state": states[state]}
163
+ except (KeyError, TypeError, ValueError):
164
+ raise NodeError("task_readback", "Task Scheduler returned invalid state") from None
165
+
166
+
167
+ def _start_host(root: Path, profile: Profile) -> subprocess.Popen:
168
+ command = [sys.executable, "-m", "cryptnode.host", "--portal-id",
169
+ str(profile.portal_id), "--install-dir", str(root)]
170
+ flags = (getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
171
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0))
172
+ try:
173
+ process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
174
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
175
+ creationflags=flags, close_fds=True)
176
+ try:
177
+ from .processes import _assign_kill_on_close
178
+ _assign_kill_on_close(process)
179
+ except NodeError:
180
+ process.kill()
181
+ process.wait(timeout=3)
182
+ raise
183
+ return process
184
+ except OSError as exc:
185
+ raise NodeError("host_start", "CryptNode Host process could not be started") from exc
186
+
187
+
188
+ def _record_controller(directory: Path, profile: Profile, state: str,
189
+ error: str, attempt: int, restart_limit: int,
190
+ restart_delay_seconds: Optional[float]) -> None:
191
+ payload = {"portal_id": profile.portal_id, "node_id": profile.node_id,
192
+ "state": state, "error": error, "log_warning": False,
193
+ "portal_configured": None, "portal_enabled": None,
194
+ "control_pid": None, "data_pid": None, "core_pid": None,
195
+ "restart_attempt": attempt, "restart_limit": restart_limit,
196
+ "restart_delay_seconds": restart_delay_seconds,
197
+ "checked_at": int(time.time())}
198
+ try:
199
+ atomic_write(directory / "runtime.json",
200
+ (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"),
201
+ public_read=True, parent_public_read=True)
202
+ except (OSError, NodeError):
203
+ pass
204
+
205
+
206
+ def _run_controller(root: Path, profile: Profile, *,
207
+ restart_delays: Sequence[float] = _LOCAL_RESTART_DELAYS) -> int:
208
+ directory = profile_dir(root, profile.portal_id)
209
+ stop_request = directory / "stop-request"
210
+ failures = 0
211
+ while not stop_request.exists():
212
+ try:
213
+ result = _start_host(root, profile).wait()
214
+ except NodeError:
215
+ result = 78
216
+ if result == 0 or stop_request.exists():
217
+ return 0
218
+ failures += 1
219
+ if failures > len(restart_delays):
220
+ _record_controller(directory, profile, "failed",
221
+ "host restart limit exhausted after exit %d" % result,
222
+ failures, len(restart_delays), None)
223
+ return 78
224
+ delay = float(restart_delays[failures - 1])
225
+ _record_controller(directory, profile, "recovering",
226
+ "Host exited with %d" % result, failures,
227
+ len(restart_delays), delay)
228
+ deadline = time.monotonic() + delay
229
+ while not stop_request.exists() and time.monotonic() < deadline:
230
+ time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
231
+ return 0
232
+
233
+
234
+ def status(profile: Profile) -> Dict[str, str]:
235
+ return _query_state(profile)
236
+
237
+
238
+ def install(root: Path, profile: Profile) -> None:
239
+ directory = profile_dir(root, profile.portal_id)
240
+ with tempfile.NamedTemporaryFile(prefix=".cryptnode-task-", suffix=".xml", dir=str(directory),
241
+ delete=False) as stream:
242
+ path = Path(stream.name)
243
+ stream.write(_task_xml(root, profile))
244
+ try:
245
+ _run("/create", "/tn", _name(profile), "/xml", str(path), "/f")
246
+ finally:
247
+ path.unlink(missing_ok=True)
248
+ if not _verify_definition(root, profile):
249
+ raise NodeError("task_readback", "Task Scheduler definition differs from CryptNode policy")
250
+
251
+
252
+ def perform(root: Path, profile: Profile, action: str) -> Dict[str, str]:
253
+ if action not in ("start", "stop", "restart", "enable", "disable"):
254
+ raise ValueError("invalid task action")
255
+ current = status(profile)
256
+ if current["exists"] == "false":
257
+ raise NodeError("task_missing", "CryptNode scheduled task is missing")
258
+ if current["exists"] != "true":
259
+ raise NodeError("task_readback", "CryptNode scheduled task state is unavailable")
260
+ directory = profile_dir(root, profile.portal_id)
261
+ if action in ("stop", "restart"):
262
+ atomic_write(directory / "stop-request", b"stop\n", parent_public_read=True)
263
+ for _ in range(60):
264
+ if status(profile)["active"] == "false":
265
+ break
266
+ time.sleep(0.25)
267
+ if status(profile)["active"] == "true":
268
+ _run("/end", "/tn", _name(profile))
269
+ if action in ("start", "restart"):
270
+ (directory / "stop-request").unlink(missing_ok=True)
271
+ _run("/run", "/tn", _name(profile))
272
+ if action in ("enable", "disable"):
273
+ _run("/change", "/tn", _name(profile), "/%s" % action)
274
+ expected = "enabled" if action in ("enable", "disable") else "active"
275
+ wanted = "false" if action in ("stop", "disable") else "true"
276
+ for _ in range(80):
277
+ result = status(profile)
278
+ if result[expected] == wanted:
279
+ if action == "stop":
280
+ (directory / "stop-request").unlink(missing_ok=True)
281
+ return result
282
+ time.sleep(0.25)
283
+ raise NodeError("task_readback", "scheduled task did not reach the requested state")
284
+
285
+
286
+ def remove(root: Path, profile: Profile) -> None:
287
+ current = status(profile)
288
+ if current["exists"] == "false":
289
+ return
290
+ if current["exists"] != "true":
291
+ raise NodeError("task_readback", "CryptNode scheduled task state is unavailable")
292
+ perform(root, profile, "stop")
293
+ _run("/delete", "/tn", _name(profile), "/f")
294
+ if status(profile)["exists"] != "false":
295
+ raise NodeError("task_readback", "scheduled task remains registered")
296
+
297
+
298
+ def main(argv=None) -> int:
299
+ parser = argparse.ArgumentParser(prog="crnode-windows-controller")
300
+ parser.add_argument("--portal-id", type=int, required=True)
301
+ parser.add_argument("--install-dir", type=Path, required=True)
302
+ args = parser.parse_args(argv)
303
+ try:
304
+ directory = profile_dir(args.install_dir, args.portal_id)
305
+ profile = read_profile(directory)
306
+ return _run_controller(args.install_dir, profile)
307
+ except (OSError, NodeError, ValueError) as exc:
308
+ print("crnode-windows-controller: %s" % exc, file=sys.stderr)
309
+ return 78
310
+
311
+
312
+ if __name__ == "__main__":
313
+ raise SystemExit(main())
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.3
2
+ Name: cryptnode
3
+ Version: 0.1.1
4
+ Summary: Managed multi-portal CryptNode client
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: cryptography==43.0.3
7
+ Requires-Dist: tomli==2.2.1; python_version < '3.11'
8
+ License-File: THIRD-PARTY.md
9
+ Description-Content-Type: text/markdown
10
+
11
+ # CryptNode 0.1.1
12
+
13
+ Managed multi-portal client for CryptPortal, with private FRP and stunnel executables.
14
+
15
+ ## Supported platforms
16
+
17
+ * Windows x86-64 with a trusted machine-level Python 3.8 or newer.
18
+ * Linux x86-64 with glibc 2.31 or newer and systemd; use a trusted virtual environment owned by the deployment user.
19
+ * Other architectures and macOS are not supported by these wheels.
20
+
21
+ ## Install
22
+
23
+ Linux, inside the intended virtual environment:
24
+
25
+ ```sh
26
+ python -m pip install cryptnode==0.1.1
27
+ python -m cryptnode --version
28
+ ```
29
+
30
+ Windows, from an administrator terminal using the intended machine-level Python:
31
+
32
+ ```powershell
33
+ python -s -m pip install --no-user cryptnode==0.1.1
34
+ python -s -m pip check
35
+ python -s -c "import cryptnode.host"
36
+ python -m cryptnode --version
37
+ ```
38
+
39
+ Windows SYSTEM scheduled tasks must be able to read and execute that Python installation, CryptNode and its complete dependency set. User AppData installations are unsuitable. Using `-s` prevents pip from treating dependencies installed only in the interactive user's site-packages as satisfied. The Python environment and package files must not be writable by untrusted users. A successful import is a prerequisite, not an end-to-end scheduled-task test.
40
+
41
+ Dependencies: cryptography==43.0.3; Python versions below 3.11 also require tomli==2.2.1. pip installs their declared dependencies automatically.
42
+
43
+ ## Deploy and connect
44
+
45
+ ```text
46
+ crnode deploy <absolute-runtime-directory>
47
+ crnode load <two-digit-node-id> <client-key-bundle.zip>
48
+ crnode list
49
+ crnode status <portal-name-or-id> --json
50
+ ```
51
+
52
+ On Windows, `load` and commands that modify SYSTEM tasks require an administrator terminal. Linux commands request sudo as needed while retaining the selected virtual environment. Obtain a matching client bundle from your CryptPortal administrator; credentials and server configuration are not included in this distribution. Protect client bundles and private keys.
53
+
54
+ ## Changes and validation limits
55
+
56
+ 0.1.1 adds Linux sudo/virtual-environment usability improvements, prompt idempotent start for already-running instances, concise Ctrl+C handling and a five-column Chinese list display. Initial load and runtime public TLS connections use private stunnel with CA/hostname checks, mutual TLS and per-handshake server-leaf certificate pin verification.
57
+
58
+ The accepted candidate reports 107 passed/11 skipped on Windows, 99 passed/19 skipped on Linux, 20 private mTLS tests and 32 handshake combinations across Ubuntu 20.04/22.04/24.04/26.04. These results do not imply all deployment scenarios passed. A Windows deployment failed when SYSTEM could not import a user-installed package, and subsequently when a globally installed package still depended on user-only cryptography. Successful retesting of that machine remains unconfirmed. Host stderr retention and clearer startup diagnostics remain improvement items.
59
+
60
+ This release packaging preserves every accepted CryptNode Python file and executable byte. It adds distribution materials and documentation; no runtime fix is claimed by the repackaging.
61
+
62
+ ## Third-party source and licenses
63
+
64
+ Each slim wheel contains the runtime binaries and licensing notices. The same PyPI release provides complete corresponding stunnel and OpenSSL source, the exact patch and build scripts in `cryptnode-0.1.1.tar.gz`. Full licenses and notices are under `cryptnode-0.1.1.dist-info/licenses/`.
65
+
66
+ Apply the supplied patch to the supplied stunnel source using the supplied platform build script. The patch changes Profile working-directory handling on Windows and adds per-handshake leaf-certificate pin checks; these modifications are identified in the patch and build matrix. The same archive contains all corresponding OpenSSL source statically linked into the private TLS executable. Neither archive contains deployment credentials.
67
+
68
+ FRP is Apache-2.0; stunnel is GPL-2.0-or-later with its upstream OpenSSL linking exception; OpenSSL is Apache-2.0. These notices describe the respective third-party components, not a new license grant for unrelated CryptNode code. Windows compiler-runtime copyright notices and exceptions are included separately.
@@ -0,0 +1,35 @@
1
+ cryptnode-0.1.1.dist-info/METADATA,sha256=vg2HgotLk4qi0fzxGQqQYGYjdlT9pU7rjk761y2AcY0,4422
2
+ cryptnode-0.1.1.dist-info/WHEEL,sha256=oagoGCm1jfYdYiHmRrWRiR009qvdh5sIXM17bPumCjo,104
3
+ cryptnode-0.1.1.dist-info/entry_points.txt,sha256=lIKyPHyI-OUmZPp3BCXgHjjMYp0B8WUdjR0xHW3HXao,80
4
+ cryptnode-0.1.1.dist-info/licenses/BUILD-MATRIX.toml,sha256=0PhdrJU1fvi_-ZEKKH6ZZ8e2STXoLKV7nr57NOSHWnk,1589
5
+ cryptnode-0.1.1.dist-info/licenses/FRP-LICENSE,sha256=xllut76FgcGL5zbIRvuRc7aezPbvlMUTWJPsVr2Sugg,11358
6
+ cryptnode-0.1.1.dist-info/licenses/GCC-RUNTIME-COPYRIGHT,sha256=pIH3cvelMzXxOzLGxU6xyFd86XcE7dN1erftQoeo6Wo,75729
7
+ cryptnode-0.1.1.dist-info/licenses/OPENSSL-LICENSE,sha256=fVRQyy0UJlG4r6MVtfI478gF2tgn2RujZ9hRa8nUnno,10175
8
+ cryptnode-0.1.1.dist-info/licenses/SOURCE-AVAILABILITY.md,sha256=AZsN3VnKytEANRdORiE5mlnRxm5ZaU-yC86RSAHDKj4,409
9
+ cryptnode-0.1.1.dist-info/licenses/STUNNEL-GPL-2.0.md,sha256=YvFx1Ni2cm32Hximu8CnD3nEvCE02DfTXIH8Yomi2E0,17941
10
+ cryptnode-0.1.1.dist-info/licenses/STUNNEL-LICENSE,sha256=cW8YsQ6sPxh16iIFQY3RsAhL0hC9F9YV32sYDPxNZbY,1768
11
+ cryptnode-0.1.1.dist-info/licenses/STUNNEL-PATCH,sha256=gcmW66rfV5gH3ZndSenfWDOLcnCp_CBLD5jQH4kBG8o,1546
12
+ cryptnode-0.1.1.dist-info/licenses/THIRD-PARTY.md,sha256=Gyw4jeSuMXnqECgCvPxbXHO2dAT-y56d9OzYuzuwD-4,1896
13
+ cryptnode/__init__.py,sha256=6DSmb43Crde3wR83wVsWr5SLZ0Kp7yCW94hL-8yDuv0,69
14
+ cryptnode/__main__.py,sha256=dvqRbpPgrhpsG7UdzAvPKJwBTp92CkcWX4qEQBzPQdc,49
15
+ cryptnode/bundles.py,sha256=lmuUbW89Q6xRk90wm7AipUataaWC9psfYDeW4-QC9K0,4932
16
+ cryptnode/certificates.py,sha256=xvhB93dr5obxJbBx0q2nh5GrtoGUCdRusHnuKshfGmo,5206
17
+ cryptnode/cli.py,sha256=NMAM0BdbXxOH3xj__dqUKre4prXcG8y4p92iXmt2ROk,18364
18
+ cryptnode/config.py,sha256=1lBRlLs3jcnryfYmMvCvJaFn8GEA-T8e6xfAQa1wxGg,3445
19
+ cryptnode/control.py,sha256=g6vRkkZN2aXAxkTozOIULTLq7sDnS6cDD65b6rXmibA,2590
20
+ cryptnode/diagnostics.py,sha256=3zu0SXYDRN0z8QanA2OyqKo3pj3HzuKRxqpPPN3eVsM,2414
21
+ cryptnode/frp.py,sha256=DHHzEzSfYJ1Nvy0uil4QT8JiriCaFV355SAIv8MwWc4,614
22
+ cryptnode/host.py,sha256=oApmxq4p_sD1e_LTiS4BiTs7AsAKRHLX3TraK-n1COM,8684
23
+ cryptnode/lifecycle.py,sha256=xDMSrVZNlBNW_iuEGL8oM5qkDKS1N9YjwPgstNu2KFQ,13608
24
+ cryptnode/logs.py,sha256=XmI-vQaMWRFzmLbhMDDu0sm7SOS0xNR4KAuTlFx_pQc,1476
25
+ cryptnode/models.py,sha256=mynX4Cji0cpoKbAehvUHt_PBUk0nl7yVV8pgUhTroBk,1039
26
+ cryptnode/paths.py,sha256=-gbWRKhT-V8tlxUsL2TyAF8YLwo4mvl5bng4F12MjhU,5603
27
+ cryptnode/processes.py,sha256=ZD_7xea8XUqdXgriKGBY6Dhxr2_9T7kTepT-_e7SZfw,7825
28
+ cryptnode/resources/__init__.py,sha256=LT1ggKn2owUOABFwH9LDCj0x8MDJ7MMCjechP89lSJs,80
29
+ cryptnode/resources/windows/crnode-core.exe,sha256=ycWfm7mVYUBbgdaPaIOErsSTad8QSJBEMVKSsOqslXo,16708608
30
+ cryptnode/resources/windows/crnode-tls.exe,sha256=bqRqNB6R8SbLbc-qm8UkLgcSVTuJr4jDJ1bSf3a9tSQ,4894888
31
+ cryptnode/security.py,sha256=kCB1NmpTdK8YyHCtDVpvl1BzlR7Itw7C-Heg2FJTWCk,5487
32
+ cryptnode/tls.py,sha256=XOKZXLJr8zLmQCfB4mKSKhe-4Wj-vRkBYQ6NlwtEC3M,2260
33
+ cryptnode/ubuntu.py,sha256=JUaseLP_7VjAtoUESAvKkNKGFI2V8QUawDMxSIMjYW4,5941
34
+ cryptnode/windows.py,sha256=E9AzL_kkR8PmqLU44HBXk7W6_N-80SfPsG-WgnG0SPU,14391
35
+ cryptnode-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: cryptnode-builder
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+ Build: 1
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ crnode = cryptnode.cli:main
3
+ crnode-host = cryptnode.host:main
@@ -0,0 +1,29 @@
1
+ schema = "cryptnode.build-matrix/v1"
2
+ version = "0.1.1"
3
+ source_date_epoch = 1789732800
4
+ frp_version = "0.71.0"
5
+ stunnel_version = "5.80"
6
+ openssl_version = "3.5.8"
7
+ frp_linux_archive_sha256 = "84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716"
8
+ frp_windows_archive_sha256 = "9e5062e3e5cf07e67144a3a4acf175ef6a2486f3605dd6cf288bae34ab39819f"
9
+ stunnel_source_sha256 = "6d0841d48de07cbbaf4a055919065bf7bb5ebc63cc15c97a2c76caa2bf285513"
10
+ openssl_source_sha256 = "a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2"
11
+ stunnel_windows_ssl_c_upstream_sha256 = "9fa3c5fd18a4f27714ba04efdf8cf3fb6f7e0612b572caeb4b17421dfe597b4d"
12
+ stunnel_windows_ssl_c_patched_sha256 = "b43a41da5f2b8c4db4ff10d75a8fc8e75f636327dd0358891f3a4f88abde2c90"
13
+
14
+ stunnel_windows_verify_c_upstream_sha256 = "e30f7209730733d04d00d2c033f17637153e632578d99225c5ee103782f0c811"
15
+ stunnel_windows_verify_c_patched_sha256 = "affeee67a08f9e76d29d7adaf6520da1fc7bb1a81240ee468f316fec6da74978"
16
+
17
+ [linux]
18
+ build_candidate = true
19
+ wheel_tag = "py3-none-manylinux_2_31_x86_64"
20
+ build_baseline = "Ubuntu 20.04 / glibc 2.31"
21
+ openssl = "3.5.8 private static link; no provider module"
22
+ stunnel_binary_sha256 = "d6ea6f98996ffcebdaddeb245becf275b3dc5d86889706f112d91742c103f7fa"
23
+
24
+ [windows]
25
+ wheel_tag = "py3-none-win_amd64"
26
+ kernel_source = "official stunnel 5.80 source with guarded Windows cwd and per-handshake DER leaf SHA256 pin patch"
27
+ openssl = "3.5.8 private static link; no provider module"
28
+ runtime_dlls = []
29
+ stunnel_binary_sha256 = "6ea46a341e91f126cb6dcfaa9bc5242e0712553b89af88c32756d27f76bdb524"