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/logs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Per-portal 60-day product log retention without capacity eviction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import BinaryIO, Optional
|
|
9
|
+
|
|
10
|
+
from .security import secure_directory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_DAILY = re.compile(r"(host|control|data|core)-(\d{4}-\d{2}-\d{2})\.log\Z")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def open_log(directory: Path, duty: str) -> Optional[BinaryIO]:
|
|
17
|
+
if duty not in ("host", "control", "data", "core"):
|
|
18
|
+
raise ValueError("invalid log duty")
|
|
19
|
+
try:
|
|
20
|
+
secure_directory(directory)
|
|
21
|
+
path = directory / ("%s-%s.log" % (duty, date.today().isoformat()))
|
|
22
|
+
if path.is_symlink():
|
|
23
|
+
raise OSError("log is a symbolic link")
|
|
24
|
+
return path.open("ab", buffering=0)
|
|
25
|
+
except OSError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def prune_expired(directory: Path, *, today: Optional[date] = None) -> int:
|
|
30
|
+
"""Only delete complete days older than the most recent 60 calendar days."""
|
|
31
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
32
|
+
return 0
|
|
33
|
+
cutoff = (today or date.today()) - timedelta(days=59)
|
|
34
|
+
removed = 0
|
|
35
|
+
for path in directory.iterdir():
|
|
36
|
+
match = _DAILY.fullmatch(path.name)
|
|
37
|
+
if not match or path.is_symlink() or not path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
day = date.fromisoformat(match.group(2))
|
|
41
|
+
except ValueError:
|
|
42
|
+
continue
|
|
43
|
+
if day < cutoff:
|
|
44
|
+
path.unlink()
|
|
45
|
+
removed += 1
|
|
46
|
+
return removed
|
|
47
|
+
|
cryptnode/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Small, immutable identities shared across the CryptNode boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NodeError(Exception):
|
|
10
|
+
def __init__(self, code: str, message: str):
|
|
11
|
+
self.code = code
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BundleIdentity:
|
|
17
|
+
portal_id: int
|
|
18
|
+
portal_name: str
|
|
19
|
+
access_host: str
|
|
20
|
+
port_base: int
|
|
21
|
+
pair_id: str
|
|
22
|
+
created_at: str
|
|
23
|
+
own_cert_sha256: str
|
|
24
|
+
peer_cert_sha256: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ClientBundle:
|
|
29
|
+
identity: BundleIdentity
|
|
30
|
+
files: Dict[str, bytes]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Profile:
|
|
35
|
+
portal_id: int
|
|
36
|
+
portal_name: str
|
|
37
|
+
node_id: int
|
|
38
|
+
access_host: str
|
|
39
|
+
port_base: int
|
|
40
|
+
pair_id: str
|
|
41
|
+
created_at: str
|
|
42
|
+
own_cert_sha256: str
|
|
43
|
+
peer_cert_sha256: str
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def local_addr(self) -> str:
|
|
47
|
+
return "127.%d.255.252" % self.portal_id
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def anchor_port(self) -> int:
|
|
51
|
+
return 20000 + self.node_id * 100
|
|
52
|
+
|
cryptnode/paths.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Product data paths and non-secret Portal Profile metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from .models import NodeError, Profile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PROFILE_KEYS = {"portal_id", "portal_name", "node_id", "access_host", "port_base",
|
|
17
|
+
"pair_id", "created_at", "own_cert_sha256", "peer_cert_sha256"}
|
|
18
|
+
_PORTAL_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_root() -> Path:
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
program_data = os.environ.get("ProgramData")
|
|
24
|
+
if not program_data:
|
|
25
|
+
raise NodeError("install_dir", "ProgramData is unavailable")
|
|
26
|
+
return Path(program_data) / "CryptNode" / "data"
|
|
27
|
+
user = os.environ.get("SUDO_USER")
|
|
28
|
+
if user:
|
|
29
|
+
import pwd
|
|
30
|
+
return Path(pwd.getpwnam(user).pw_dir) / "CryptNode"
|
|
31
|
+
return Path.home() / "CryptNode"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pointer_path() -> Path:
|
|
35
|
+
if os.name == "nt":
|
|
36
|
+
return default_root().parent / "install-dir"
|
|
37
|
+
return Path("/etc/cryptnode/install-dir")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def active_root() -> Path:
|
|
41
|
+
override = os.environ.get("CRYPTNODE_INSTALL_DIR")
|
|
42
|
+
if override:
|
|
43
|
+
return checked_absolute(Path(override))
|
|
44
|
+
pointer = pointer_path()
|
|
45
|
+
try:
|
|
46
|
+
pointer_stat = pointer.lstat()
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
pointer_stat = None
|
|
49
|
+
except PermissionError:
|
|
50
|
+
raise NodeError("install_dir_access", "CryptNode installation pointer is not readable") from None
|
|
51
|
+
if pointer_stat is not None:
|
|
52
|
+
if not stat.S_ISREG(pointer_stat.st_mode):
|
|
53
|
+
raise NodeError("path_link", "CryptNode installation pointer is not a regular file")
|
|
54
|
+
try:
|
|
55
|
+
return checked_absolute(Path(pointer.read_text(encoding="utf-8").strip()))
|
|
56
|
+
except PermissionError:
|
|
57
|
+
raise NodeError("install_dir_access", "CryptNode installation pointer is not readable") from None
|
|
58
|
+
raise NodeError("not_deployed", "尚未部署;请先执行 crnode deploy <安装目录>")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def checked_absolute(path: Path) -> Path:
|
|
62
|
+
path = Path(path)
|
|
63
|
+
if not path.is_absolute() or ".." in path.parts or path == Path(path.anchor):
|
|
64
|
+
raise NodeError("install_dir", "install_dir must be an absolute non-root path")
|
|
65
|
+
current = Path(path.anchor)
|
|
66
|
+
for part in path.parts[1:]:
|
|
67
|
+
current = current / part
|
|
68
|
+
try:
|
|
69
|
+
metadata = current.lstat()
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
continue
|
|
72
|
+
except NotADirectoryError:
|
|
73
|
+
raise NodeError("install_dir", "install_dir traverses a non-directory") from None
|
|
74
|
+
except PermissionError:
|
|
75
|
+
raise NodeError("install_dir_access", "install_dir ancestor is not readable") from None
|
|
76
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
77
|
+
raise NodeError("path_link", "install_dir cannot traverse a symbolic link")
|
|
78
|
+
if os.name == "nt":
|
|
79
|
+
attrs = metadata.st_file_attributes
|
|
80
|
+
if attrs & 0x400:
|
|
81
|
+
raise NodeError("path_link", "install_dir cannot traverse a reparse point")
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profile_dir(root: Path, portal_id: int) -> Path:
|
|
86
|
+
if type(portal_id) is not int or not 1 <= portal_id <= 99:
|
|
87
|
+
raise NodeError("portal_id", "portal_id must be 1..99")
|
|
88
|
+
return checked_absolute(root) / "portals" / ("portal-%02d" % portal_id)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def profile_json(profile: Profile) -> bytes:
|
|
92
|
+
return (json.dumps(asdict(profile), sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_profile(directory: Path) -> Profile:
|
|
96
|
+
path = directory / "portal.json"
|
|
97
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > 8192:
|
|
98
|
+
raise NodeError("profile_invalid", "Portal Profile metadata is missing or unsafe")
|
|
99
|
+
try:
|
|
100
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
if not isinstance(value, dict) or set(value) != _PROFILE_KEYS:
|
|
102
|
+
raise ValueError("profile keys")
|
|
103
|
+
profile = Profile(**value)
|
|
104
|
+
if (type(profile.portal_id) is not int or not 1 <= profile.portal_id <= 99
|
|
105
|
+
or type(profile.node_id) is not int or not 1 <= profile.node_id <= 99
|
|
106
|
+
or not isinstance(profile.portal_name, str)
|
|
107
|
+
or not _PORTAL_NAME.fullmatch(profile.portal_name)
|
|
108
|
+
or directory.name != "portal-%02d" % profile.portal_id):
|
|
109
|
+
raise ValueError("profile identity")
|
|
110
|
+
return profile
|
|
111
|
+
except (OSError, TypeError, ValueError, UnicodeError):
|
|
112
|
+
raise NodeError("profile_invalid", "Portal Profile metadata is invalid") from None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def profiles(root: Path) -> Iterable[Profile]:
|
|
116
|
+
parent = checked_absolute(root) / "portals"
|
|
117
|
+
if not parent.exists():
|
|
118
|
+
return ()
|
|
119
|
+
result = []
|
|
120
|
+
for directory in sorted(parent.iterdir()):
|
|
121
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
122
|
+
raise NodeError("profile_invalid", "Portal Profile entry is unsafe")
|
|
123
|
+
result.append(read_profile(directory))
|
|
124
|
+
names = [profile.portal_name for profile in result]
|
|
125
|
+
if len(names) != len(set(names)):
|
|
126
|
+
raise NodeError("profile_ambiguous", "Portal Profile names are duplicated")
|
|
127
|
+
return tuple(result)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def select_profile(root: Path, selector: str) -> Profile:
|
|
131
|
+
rows = tuple(profiles(root))
|
|
132
|
+
matches = [row for row in rows if selector in (str(row.portal_id), row.portal_name)]
|
|
133
|
+
if len(matches) != 1:
|
|
134
|
+
raise NodeError("portal_selection", "portal does not select one unique Profile")
|
|
135
|
+
return matches[0]
|
cryptnode/processes.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Private child processes, local readiness and bounded shutdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from .logs import open_log
|
|
15
|
+
from .models import NodeError, Profile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_WINDOWS_JOB = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _assign_kill_on_close(process: subprocess.Popen) -> None:
|
|
22
|
+
"""Keep all private children in the Host's kill-on-close Windows Job."""
|
|
23
|
+
if os.name != "nt":
|
|
24
|
+
return
|
|
25
|
+
import ctypes
|
|
26
|
+
from ctypes import wintypes
|
|
27
|
+
|
|
28
|
+
class BasicLimit(ctypes.Structure):
|
|
29
|
+
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
30
|
+
("PerJobUserTimeLimit", ctypes.c_longlong), ("LimitFlags", wintypes.DWORD),
|
|
31
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
32
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
33
|
+
("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ctypes.c_size_t),
|
|
34
|
+
("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD)]
|
|
35
|
+
|
|
36
|
+
class IoCounters(ctypes.Structure):
|
|
37
|
+
_fields_ = [("ReadOperationCount", ctypes.c_ulonglong),
|
|
38
|
+
("WriteOperationCount", ctypes.c_ulonglong),
|
|
39
|
+
("OtherOperationCount", ctypes.c_ulonglong),
|
|
40
|
+
("ReadTransferCount", ctypes.c_ulonglong),
|
|
41
|
+
("WriteTransferCount", ctypes.c_ulonglong),
|
|
42
|
+
("OtherTransferCount", ctypes.c_ulonglong)]
|
|
43
|
+
|
|
44
|
+
class ExtendedLimit(ctypes.Structure):
|
|
45
|
+
_fields_ = [("BasicLimitInformation", BasicLimit), ("IoInfo", IoCounters),
|
|
46
|
+
("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t),
|
|
47
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
48
|
+
("PeakJobMemoryUsed", ctypes.c_size_t)]
|
|
49
|
+
|
|
50
|
+
kernel = ctypes.windll.kernel32
|
|
51
|
+
kernel.CreateJobObjectW.restype = wintypes.HANDLE
|
|
52
|
+
kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
53
|
+
kernel.OpenProcess.restype = wintypes.HANDLE
|
|
54
|
+
kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
55
|
+
kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
|
56
|
+
kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
57
|
+
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
58
|
+
global _WINDOWS_JOB
|
|
59
|
+
if _WINDOWS_JOB is None:
|
|
60
|
+
job = kernel.CreateJobObjectW(None, None)
|
|
61
|
+
if not job:
|
|
62
|
+
raise NodeError("job_object", "cannot create CryptNode process Job")
|
|
63
|
+
limits = ExtendedLimit()
|
|
64
|
+
limits.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
65
|
+
if not kernel.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
|
|
66
|
+
kernel.CloseHandle(job)
|
|
67
|
+
raise NodeError("job_object", "cannot set CryptNode Job exit policy")
|
|
68
|
+
_WINDOWS_JOB = job
|
|
69
|
+
handle = kernel.OpenProcess(0x0100 | 0x0001, False, process.pid)
|
|
70
|
+
if not handle:
|
|
71
|
+
raise NodeError("job_object", "cannot open private child process")
|
|
72
|
+
try:
|
|
73
|
+
if not kernel.AssignProcessToJobObject(_WINDOWS_JOB, handle):
|
|
74
|
+
raise NodeError("job_object", "cannot attach private child to CryptNode Job")
|
|
75
|
+
finally:
|
|
76
|
+
kernel.CloseHandle(handle)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resource_binary(name: str) -> Path:
|
|
80
|
+
if name not in ("crnode-core", "crnode-tls"):
|
|
81
|
+
raise ValueError("unknown private binary")
|
|
82
|
+
platform = "windows" if os.name == "nt" else "linux"
|
|
83
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
84
|
+
path = Path(__file__).parent / "resources" / platform / (name + suffix)
|
|
85
|
+
if path.is_symlink() or not path.is_file():
|
|
86
|
+
raise NodeError("kernel_missing", "private CryptNode kernel is missing")
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def port_available(profile: Profile, port: int) -> bool:
|
|
91
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
92
|
+
try:
|
|
93
|
+
if os.name != "nt":
|
|
94
|
+
# A just-closed tunnel can leave TCP connections in TIME_WAIT.
|
|
95
|
+
# Reuse their address, but still reject a live listener.
|
|
96
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
97
|
+
probe.bind((profile.local_addr, port))
|
|
98
|
+
return True
|
|
99
|
+
except OSError:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def require_ports(profile: Profile, ports: Sequence[int]) -> None:
|
|
104
|
+
for port in ports:
|
|
105
|
+
if not port_available(profile, port):
|
|
106
|
+
raise NodeError("port_conflict", "%s:%d is occupied" % (profile.local_addr, port))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def wait_listener(profile: Profile, port: int, process: subprocess.Popen, timeout: float = 10.0) -> None:
|
|
110
|
+
deadline = time.monotonic() + timeout
|
|
111
|
+
while time.monotonic() < deadline:
|
|
112
|
+
if process.poll() is not None:
|
|
113
|
+
raise NodeError("kernel_exit", "private kernel exited before listener readiness")
|
|
114
|
+
try:
|
|
115
|
+
with socket.create_connection((profile.local_addr, port), timeout=0.2):
|
|
116
|
+
return
|
|
117
|
+
except OSError:
|
|
118
|
+
time.sleep(0.1)
|
|
119
|
+
raise NodeError("kernel_timeout", "private kernel listener did not become ready")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Child:
|
|
123
|
+
def __init__(self, command: Sequence[str], directory: Path, duty: str, *, env=None) -> None:
|
|
124
|
+
self.directory = directory
|
|
125
|
+
self.duty = duty
|
|
126
|
+
self.log_error = False
|
|
127
|
+
self.log = None
|
|
128
|
+
self.log_day = None
|
|
129
|
+
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
130
|
+
try:
|
|
131
|
+
self.process = subprocess.Popen(list(command), cwd=str(directory), stdin=subprocess.DEVNULL,
|
|
132
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
133
|
+
creationflags=flags, close_fds=True, env=env)
|
|
134
|
+
try:
|
|
135
|
+
_assign_kill_on_close(self.process)
|
|
136
|
+
except NodeError:
|
|
137
|
+
self.process.kill()
|
|
138
|
+
self.process.wait(timeout=3)
|
|
139
|
+
raise
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise NodeError("kernel_start", "private kernel failed to start") from exc
|
|
142
|
+
self.pump = threading.Thread(target=self._drain, daemon=True)
|
|
143
|
+
self.pump.start()
|
|
144
|
+
|
|
145
|
+
def _drain(self) -> None:
|
|
146
|
+
assert self.process.stdout is not None
|
|
147
|
+
try:
|
|
148
|
+
while True:
|
|
149
|
+
chunk = self.process.stdout.read1(8192)
|
|
150
|
+
if not chunk:
|
|
151
|
+
break
|
|
152
|
+
day = date.today()
|
|
153
|
+
if self.log_day != day:
|
|
154
|
+
if self.log is not None:
|
|
155
|
+
self.log.close()
|
|
156
|
+
self.log = open_log(self.directory / "logs", self.duty)
|
|
157
|
+
self.log_day = day
|
|
158
|
+
if self.log is None:
|
|
159
|
+
self.log_error = True
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
self.log.write(chunk)
|
|
163
|
+
except OSError:
|
|
164
|
+
self.log_error = True
|
|
165
|
+
self.log.close()
|
|
166
|
+
self.log = None
|
|
167
|
+
finally:
|
|
168
|
+
self.process.stdout.close()
|
|
169
|
+
if self.log is not None:
|
|
170
|
+
self.log.close()
|
|
171
|
+
|
|
172
|
+
def poll(self) -> Optional[int]:
|
|
173
|
+
return self.process.poll()
|
|
174
|
+
|
|
175
|
+
def stop(self, timeout: float = 8.0) -> None:
|
|
176
|
+
if self.process.poll() is None:
|
|
177
|
+
self.process.terminate()
|
|
178
|
+
try:
|
|
179
|
+
self.process.wait(timeout=timeout)
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
self.process.kill()
|
|
182
|
+
self.process.wait(timeout=3)
|
|
183
|
+
self.pump.join(timeout=3)
|
|
Binary file
|
|
Binary file
|
cryptnode/security.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Privilege, path and atomic-file safeguards for product-owned state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import stat
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
from .models import NodeError
|
|
13
|
+
from .paths import checked_absolute
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_administrator() -> bool:
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
import ctypes
|
|
19
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
20
|
+
return os.geteuid() == 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def require_administrator() -> None:
|
|
24
|
+
if not is_administrator():
|
|
25
|
+
hint = "请使用 sudo crnode ... 执行此操作"
|
|
26
|
+
if os.name == "nt":
|
|
27
|
+
hint = "此操作需要管理员权限;请先进入 gsudo 环境,或运行 gsudo crnode ..."
|
|
28
|
+
if shutil.which("gsudo") is None:
|
|
29
|
+
hint += ";未找到 gsudo,可运行 winget install --id gerardog.gsudo -e 安装"
|
|
30
|
+
raise NodeError("administrator_required", hint)
|
|
31
|
+
if os.name != "nt":
|
|
32
|
+
trusted_runtime_account()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def trusted_runtime_account():
|
|
36
|
+
"""Bind a privileged Linux command to its deployment user's venv."""
|
|
37
|
+
import pwd
|
|
38
|
+
|
|
39
|
+
name = os.environ.get("SUDO_USER")
|
|
40
|
+
if not name or name == "root":
|
|
41
|
+
raise NodeError("runtime_user", "需要从可信部署用户的 sudo 会话执行")
|
|
42
|
+
try:
|
|
43
|
+
account = pwd.getpwnam(name)
|
|
44
|
+
except KeyError:
|
|
45
|
+
raise NodeError("runtime_user", "部署用户不存在") from None
|
|
46
|
+
prefix = Path(sys.prefix)
|
|
47
|
+
if (account.pw_uid == 0 or sys.prefix == sys.base_prefix
|
|
48
|
+
or prefix.stat().st_uid != account.pw_uid):
|
|
49
|
+
raise NodeError("runtime_python", "需要部署用户自有的可信虚拟环境")
|
|
50
|
+
if prefix.stat().st_mode & 0o002:
|
|
51
|
+
raise NodeError("runtime_python", "虚拟环境不能允许所有用户写入")
|
|
52
|
+
return account
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _restrict_windows(path: Path, directory: bool, public_read: bool = False) -> None:
|
|
56
|
+
# One DACL replacement removes inherited and pre-existing explicit grants.
|
|
57
|
+
# Users may read only selected metadata; that ACE never inherits to children.
|
|
58
|
+
import ctypes
|
|
59
|
+
from ctypes import wintypes
|
|
60
|
+
|
|
61
|
+
entries = "(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" if directory else "(A;;FA;;;SY)(A;;FA;;;BA)"
|
|
62
|
+
if public_read:
|
|
63
|
+
entries += "(A;;GRGX;;;BU)" if directory else "(A;;FR;;;BU)"
|
|
64
|
+
descriptor = ctypes.c_void_p()
|
|
65
|
+
advapi = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
66
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
67
|
+
convert = advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW
|
|
68
|
+
convert.argtypes = [wintypes.LPCWSTR, wintypes.DWORD,
|
|
69
|
+
ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.DWORD)]
|
|
70
|
+
convert.restype = wintypes.BOOL
|
|
71
|
+
set_file = advapi.SetFileSecurityW
|
|
72
|
+
set_file.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.c_void_p]
|
|
73
|
+
set_file.restype = wintypes.BOOL
|
|
74
|
+
kernel.LocalFree.argtypes = [ctypes.c_void_p]
|
|
75
|
+
kernel.LocalFree.restype = ctypes.c_void_p
|
|
76
|
+
if not convert("D:P" + entries, 1, ctypes.byref(descriptor), None):
|
|
77
|
+
raise NodeError("acl_failed", "cannot construct CryptNode product ACL")
|
|
78
|
+
try:
|
|
79
|
+
# DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION
|
|
80
|
+
if not set_file(str(path), 0x80000004, descriptor):
|
|
81
|
+
raise NodeError("acl_failed", "cannot restrict CryptNode product state")
|
|
82
|
+
finally:
|
|
83
|
+
kernel.LocalFree(descriptor)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def secure_directory(path: Path, *, public_read: bool = False) -> None:
|
|
87
|
+
checked_absolute(path)
|
|
88
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
89
|
+
if path.is_symlink() or not path.is_dir():
|
|
90
|
+
raise NodeError("path_link", "product directory is unsafe")
|
|
91
|
+
if os.name == "nt":
|
|
92
|
+
_restrict_windows(path, True, public_read)
|
|
93
|
+
else:
|
|
94
|
+
path.chmod(0o700)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def atomic_write(path: Path, data: bytes, *, secret: bool = False,
|
|
98
|
+
public_read: bool = False, parent_public_read: bool = False) -> None:
|
|
99
|
+
if secret and public_read:
|
|
100
|
+
raise ValueError("secret files cannot be public")
|
|
101
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
102
|
+
raise NodeError("path_link", "product file path is unsafe")
|
|
103
|
+
secure_directory(path.parent, public_read=parent_public_read)
|
|
104
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".cryptnode-", dir=str(path.parent))
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
107
|
+
stream.write(data)
|
|
108
|
+
stream.flush()
|
|
109
|
+
os.fsync(stream.fileno())
|
|
110
|
+
if os.name == "nt":
|
|
111
|
+
_restrict_windows(Path(temporary), False, public_read)
|
|
112
|
+
else:
|
|
113
|
+
os.chmod(temporary, 0o600 if secret else 0o640)
|
|
114
|
+
os.replace(temporary, path)
|
|
115
|
+
if os.name != "nt":
|
|
116
|
+
directory = os.open(str(path.parent), os.O_RDONLY | os.O_DIRECTORY)
|
|
117
|
+
try:
|
|
118
|
+
os.fsync(directory)
|
|
119
|
+
finally:
|
|
120
|
+
os.close(directory)
|
|
121
|
+
finally:
|
|
122
|
+
if os.path.exists(temporary):
|
|
123
|
+
os.unlink(temporary)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def assert_private_file(path: Path) -> None:
|
|
127
|
+
if path.is_symlink() or not path.is_file():
|
|
128
|
+
raise NodeError("file_unsafe", "private file is missing or unsafe")
|
|
129
|
+
if os.name != "nt":
|
|
130
|
+
metadata = path.stat()
|
|
131
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
|
132
|
+
or stat.S_IMODE(metadata.st_mode) & 0o077):
|
|
133
|
+
raise NodeError("file_unsafe", "private file permissions are too broad")
|
|
134
|
+
|
|
135
|
+
|
cryptnode/tls.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Fixed stunnel client policy for the two CryptNode duties."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .models import Profile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_policy(profile: Profile, duty: str) -> str:
|
|
12
|
+
if duty not in ("control", "data"):
|
|
13
|
+
raise ValueError("duty must be control or data")
|
|
14
|
+
local_port = 13200 if duty == "control" else 13201
|
|
15
|
+
remote_port = profile.port_base if duty == "control" else profile.port_base + 1
|
|
16
|
+
lines = (["foreground = yes"] if os.name != "nt" else []) + [
|
|
17
|
+
"debug = notice", "sslVersionMin = TLSv1.2",
|
|
18
|
+
"sslVersionMax = TLSv1.3",
|
|
19
|
+
"ciphers = ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
|
|
20
|
+
"ciphersuites = TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256",
|
|
21
|
+
"options = NO_COMPRESSION", "renegotiation = no", "sessionResume = no",
|
|
22
|
+
"[cryptnode-%s]" % duty, "client = yes", "verifyChain = yes",
|
|
23
|
+
"CAfile = certs/ca.pem", "cert = certs/cert.pem", "key = certs/key.pem",
|
|
24
|
+
"checkHost = node.crypt", "sni = node.crypt",
|
|
25
|
+
"accept = %s:%d" % (profile.local_addr, local_port),
|
|
26
|
+
"connect = %s:%d" % (profile.access_host, remote_port),
|
|
27
|
+
]
|
|
28
|
+
return "\n".join(lines) + "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def start_tunnel(directory, profile: Profile, duty: str):
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from .models import NodeError
|
|
34
|
+
from .processes import Child, resource_binary, wait_listener
|
|
35
|
+
|
|
36
|
+
directory = Path(directory)
|
|
37
|
+
policy = directory / ("%s.conf" % duty)
|
|
38
|
+
if policy.is_symlink() or policy.read_text(encoding="utf-8") != render_policy(profile, duty):
|
|
39
|
+
raise NodeError("tls_policy", "stunnel policy differs from the fixed CryptNode policy")
|
|
40
|
+
if not isinstance(profile.peer_cert_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", profile.peer_cert_sha256):
|
|
41
|
+
raise NodeError("peer_fingerprint", "门户证书指纹缺失或格式错误")
|
|
42
|
+
environment = dict(os.environ, CRYPTNODE_PEER_SHA256=profile.peer_cert_sha256)
|
|
43
|
+
child = Child([str(resource_binary("crnode-tls")), str(policy)], directory, duty, env=environment)
|
|
44
|
+
try:
|
|
45
|
+
wait_listener(profile, 13200 if duty == "control" else 13201, child.process)
|
|
46
|
+
except Exception:
|
|
47
|
+
child.stop()
|
|
48
|
+
raise
|
|
49
|
+
return child
|