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/__init__.py +4 -0
- cryptnode/__main__.py +4 -0
- cryptnode/bundles.py +101 -0
- cryptnode/certificates.py +98 -0
- cryptnode/cli.py +334 -0
- cryptnode/config.py +79 -0
- cryptnode/control.py +66 -0
- cryptnode/diagnostics.py +53 -0
- cryptnode/frp.py +18 -0
- cryptnode/host.py +202 -0
- cryptnode/lifecycle.py +307 -0
- cryptnode/logs.py +47 -0
- cryptnode/models.py +52 -0
- cryptnode/paths.py +135 -0
- cryptnode/processes.py +183 -0
- cryptnode/resources/__init__.py +2 -0
- cryptnode/resources/windows/crnode-core.exe +0 -0
- cryptnode/resources/windows/crnode-tls.exe +0 -0
- cryptnode/security.py +135 -0
- cryptnode/tls.py +49 -0
- cryptnode/ubuntu.py +144 -0
- cryptnode/windows.py +313 -0
- cryptnode-0.1.1.dist-info/METADATA +68 -0
- cryptnode-0.1.1.dist-info/RECORD +35 -0
- cryptnode-0.1.1.dist-info/WHEEL +5 -0
- cryptnode-0.1.1.dist-info/entry_points.txt +3 -0
- cryptnode-0.1.1.dist-info/licenses/BUILD-MATRIX.toml +29 -0
- cryptnode-0.1.1.dist-info/licenses/FRP-LICENSE +202 -0
- cryptnode-0.1.1.dist-info/licenses/GCC-RUNTIME-COPYRIGHT +1714 -0
- cryptnode-0.1.1.dist-info/licenses/OPENSSL-LICENSE +177 -0
- cryptnode-0.1.1.dist-info/licenses/SOURCE-AVAILABILITY.md +1 -0
- cryptnode-0.1.1.dist-info/licenses/STUNNEL-GPL-2.0.md +336 -0
- cryptnode-0.1.1.dist-info/licenses/STUNNEL-LICENSE +34 -0
- cryptnode-0.1.1.dist-info/licenses/STUNNEL-PATCH +44 -0
- cryptnode-0.1.1.dist-info/licenses/THIRD-PARTY.md +30 -0
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,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"
|