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/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)
@@ -0,0 +1,2 @@
1
+ """Platform-private kernels are inserted into the published platform wheel."""
2
+
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