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/__init__.py
ADDED
cryptnode/__main__.py
ADDED
cryptnode/bundles.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Bounded, exact CryptPortal client.zip parser; ZIP bytes never become paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
from typing import Dict
|
|
12
|
+
import zipfile
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import tomllib
|
|
16
|
+
except ImportError:
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
from .certificates import certificate_sha256, validate_client_cert
|
|
20
|
+
from .models import BundleIdentity, ClientBundle, NodeError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MEMBERS = frozenset({"manifest.toml", "cert.pem", "key.pem", "ca.pem", "frp-token"})
|
|
24
|
+
MAX_ARCHIVE = 4 * 1024 * 1024
|
|
25
|
+
MAX_MEMBER = 1024 * 1024
|
|
26
|
+
_PORTAL_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
|
27
|
+
_HOST = re.compile(r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
|
28
|
+
_VISIBLE_ID = re.compile(r"[!-~]{1,128}\Z")
|
|
29
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
|
|
30
|
+
_TOKEN = re.compile(rb"[0-9a-f]{64}\n\Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_bundle(data: bytes) -> ClientBundle:
|
|
34
|
+
if len(data) > MAX_ARCHIVE:
|
|
35
|
+
raise NodeError("bundle_size", "client.zip exceeds the size limit")
|
|
36
|
+
try:
|
|
37
|
+
archive = zipfile.ZipFile(io.BytesIO(data), "r")
|
|
38
|
+
infos = archive.infolist()
|
|
39
|
+
except (zipfile.BadZipFile, OSError):
|
|
40
|
+
raise NodeError("bundle_format", "invalid client.zip") from None
|
|
41
|
+
names = [item.filename for item in infos]
|
|
42
|
+
if len(names) != len(set(names)) or set(names) != MEMBERS:
|
|
43
|
+
raise NodeError("bundle_members", "client.zip member set is invalid")
|
|
44
|
+
files: Dict[str, bytes] = {}
|
|
45
|
+
for info in infos:
|
|
46
|
+
path = PurePosixPath(info.filename)
|
|
47
|
+
mode = info.external_attr >> 16
|
|
48
|
+
required = 0o600 if info.filename in ("key.pem", "frp-token") else 0o644
|
|
49
|
+
if (path.is_absolute() or len(path.parts) != 1 or ".." in path.parts
|
|
50
|
+
or not stat.S_ISREG(mode) or stat.S_IMODE(mode) != required
|
|
51
|
+
or info.file_size > MAX_MEMBER or info.compress_size > MAX_MEMBER):
|
|
52
|
+
raise NodeError("bundle_member", "client.zip contains unsafe member")
|
|
53
|
+
try:
|
|
54
|
+
files[info.filename] = archive.read(info)
|
|
55
|
+
except (RuntimeError, zipfile.BadZipFile, OSError):
|
|
56
|
+
raise NodeError("bundle_member", "client.zip member cannot be read") from None
|
|
57
|
+
try:
|
|
58
|
+
manifest = tomllib.loads(files["manifest.toml"].decode("utf-8"))
|
|
59
|
+
keys = {"schema_version", "portal_id", "portal_name", "access_host", "port_base",
|
|
60
|
+
"role", "side", "pair_id", "created_at", "own_cert_sha256",
|
|
61
|
+
"peer_cert_sha256", "files"}
|
|
62
|
+
if set(manifest) != keys or manifest["schema_version"] != 1:
|
|
63
|
+
raise ValueError("manifest keys")
|
|
64
|
+
portal_id = manifest["portal_id"]
|
|
65
|
+
port_base = manifest["port_base"]
|
|
66
|
+
if (type(portal_id) is not int or not 1 <= portal_id <= 99
|
|
67
|
+
or type(port_base) is not int or not 1 <= port_base <= 65533
|
|
68
|
+
or manifest["role"] != "cryptnode" or manifest["side"] != "client"):
|
|
69
|
+
raise ValueError("identity")
|
|
70
|
+
portal_name = manifest["portal_name"]
|
|
71
|
+
access_host = manifest["access_host"]
|
|
72
|
+
pair_id = manifest["pair_id"]
|
|
73
|
+
created_at = manifest["created_at"]
|
|
74
|
+
if (not isinstance(portal_name, str) or not _PORTAL_NAME.fullmatch(portal_name)
|
|
75
|
+
or not isinstance(access_host, str) or not _HOST.fullmatch(access_host)
|
|
76
|
+
or not isinstance(pair_id, str) or not _VISIBLE_ID.fullmatch(pair_id)
|
|
77
|
+
or not isinstance(created_at, str)):
|
|
78
|
+
raise ValueError("identity text")
|
|
79
|
+
created = datetime.fromisoformat(created_at)
|
|
80
|
+
if created.tzinfo is None or created.utcoffset() is None:
|
|
81
|
+
raise ValueError("creation time")
|
|
82
|
+
if set(manifest["files"]) != MEMBERS - {"manifest.toml"}:
|
|
83
|
+
raise ValueError("file set")
|
|
84
|
+
for name, digest in manifest["files"].items():
|
|
85
|
+
if not isinstance(digest, str) or not _DIGEST.fullmatch(digest):
|
|
86
|
+
raise ValueError("digest format")
|
|
87
|
+
if hashlib.sha256(files[name]).hexdigest() != digest:
|
|
88
|
+
raise ValueError("digest")
|
|
89
|
+
own = manifest["own_cert_sha256"]
|
|
90
|
+
peer = manifest["peer_cert_sha256"]
|
|
91
|
+
if (not isinstance(own, str) or not isinstance(peer, str)
|
|
92
|
+
or not _DIGEST.fullmatch(peer) or own != certificate_sha256(files["cert.pem"])):
|
|
93
|
+
raise ValueError("fingerprint")
|
|
94
|
+
if not _TOKEN.fullmatch(files["frp-token"]):
|
|
95
|
+
raise ValueError("token")
|
|
96
|
+
validate_client_cert(files["cert.pem"], files["key.pem"], files["ca.pem"], created_at)
|
|
97
|
+
except (KeyError, TypeError, ValueError, UnicodeError, NodeError):
|
|
98
|
+
raise NodeError("bundle_invalid", "client.zip identity, digest or certificate is invalid") from None
|
|
99
|
+
return ClientBundle(BundleIdentity(portal_id, portal_name, access_host, port_base,
|
|
100
|
+
pair_id, created_at, own, peer), files)
|
|
101
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Strict, local cryptography checks for a current CryptPortal client pair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
import hashlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
14
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID
|
|
15
|
+
|
|
16
|
+
from .models import NodeError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def certificate_sha256(pem: bytes) -> str:
|
|
20
|
+
try:
|
|
21
|
+
certificate = x509.load_pem_x509_certificate(pem)
|
|
22
|
+
except (ValueError, TypeError):
|
|
23
|
+
raise NodeError("certificate_invalid", "invalid certificate PEM") from None
|
|
24
|
+
return hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_client_cert(cert_pem: bytes, key_pem: bytes, ca_pem: bytes,
|
|
28
|
+
created_at: str) -> None:
|
|
29
|
+
try:
|
|
30
|
+
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
31
|
+
ca = x509.load_pem_x509_certificate(ca_pem)
|
|
32
|
+
key = serialization.load_pem_private_key(key_pem, password=None)
|
|
33
|
+
created = datetime.fromisoformat(created_at)
|
|
34
|
+
if created.tzinfo is None or created.utcoffset() is None:
|
|
35
|
+
raise ValueError("naive creation time")
|
|
36
|
+
expected_before = created.astimezone(timezone.utc) - timedelta(hours=24)
|
|
37
|
+
expected_after = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
|
|
38
|
+
if cert.issuer != ca.subject or ca.issuer != ca.subject:
|
|
39
|
+
raise ValueError("issuer")
|
|
40
|
+
if (cert.not_valid_before_utc != expected_before or cert.not_valid_after_utc != expected_after
|
|
41
|
+
or ca.not_valid_before_utc != expected_before or ca.not_valid_after_utc != expected_after):
|
|
42
|
+
raise ValueError("certificate time")
|
|
43
|
+
for item in (cert, ca):
|
|
44
|
+
if item.signature_hash_algorithm.name != "sha256":
|
|
45
|
+
raise ValueError("signature")
|
|
46
|
+
public = item.public_key()
|
|
47
|
+
if not isinstance(public, rsa.RSAPublicKey) or public.key_size != 2048:
|
|
48
|
+
raise ValueError("RSA key")
|
|
49
|
+
if not isinstance(key, rsa.RSAPrivateKey) or key.key_size != 2048:
|
|
50
|
+
raise ValueError("private RSA key")
|
|
51
|
+
if key_pem.count(b"-----BEGIN PRIVATE KEY-----") != 1 or b"ENCRYPTED" in key_pem:
|
|
52
|
+
raise ValueError("PKCS#8")
|
|
53
|
+
if (key.public_key().public_bytes(serialization.Encoding.DER,
|
|
54
|
+
serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
55
|
+
!= cert.public_key().public_bytes(serialization.Encoding.DER,
|
|
56
|
+
serialization.PublicFormat.SubjectPublicKeyInfo)):
|
|
57
|
+
raise ValueError("key mismatch")
|
|
58
|
+
ca.public_key().verify(ca.signature, ca.tbs_certificate_bytes,
|
|
59
|
+
padding.PKCS1v15(), hashes.SHA256())
|
|
60
|
+
ca.public_key().verify(cert.signature, cert.tbs_certificate_bytes,
|
|
61
|
+
padding.PKCS1v15(), hashes.SHA256())
|
|
62
|
+
if not ca.extensions.get_extension_for_class(x509.BasicConstraints).value.ca:
|
|
63
|
+
raise ValueError("CA")
|
|
64
|
+
if cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca:
|
|
65
|
+
raise ValueError("client CA")
|
|
66
|
+
usages = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
|
|
67
|
+
if set(usages) != {ExtendedKeyUsageOID.CLIENT_AUTH}:
|
|
68
|
+
raise ValueError("client usage")
|
|
69
|
+
if cert.public_key().key_size != 2048:
|
|
70
|
+
raise ValueError("certificate key size")
|
|
71
|
+
except (ValueError, TypeError, KeyError, x509.ExtensionNotFound):
|
|
72
|
+
raise NodeError("certificate_invalid", "client certificate, chain, key or time is invalid") from None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_peer_tls(directory: Path, access_host: str, port: int, fingerprint: str) -> None:
|
|
76
|
+
"""Check live server identity and exact peer fingerprint before first binding."""
|
|
77
|
+
certs = directory / "certs"
|
|
78
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
79
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
80
|
+
context.check_hostname = True
|
|
81
|
+
context.load_verify_locations(cafile=str(certs / "ca.pem"))
|
|
82
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
83
|
+
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
84
|
+
context.set_ciphers("ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256")
|
|
85
|
+
context.options |= ssl.OP_NO_COMPRESSION
|
|
86
|
+
if hasattr(ssl, "OP_NO_TICKET"):
|
|
87
|
+
context.options |= ssl.OP_NO_TICKET
|
|
88
|
+
if hasattr(ssl, "OP_NO_RENEGOTIATION"):
|
|
89
|
+
context.options |= ssl.OP_NO_RENEGOTIATION
|
|
90
|
+
context.load_cert_chain(str(certs / "cert.pem"), str(certs / "key.pem"))
|
|
91
|
+
try:
|
|
92
|
+
with socket.create_connection((access_host, port), timeout=5) as raw:
|
|
93
|
+
with context.wrap_socket(raw, server_hostname="node.crypt") as secure:
|
|
94
|
+
peer = secure.getpeercert(binary_form=True)
|
|
95
|
+
if hashlib.sha256(peer).hexdigest() != fingerprint:
|
|
96
|
+
raise NodeError("peer_fingerprint", "portal server certificate differs from client.zip")
|
|
97
|
+
except (OSError, ssl.SSLError) as exc:
|
|
98
|
+
raise NodeError("peer_tls", "portal TLS identity is unavailable or invalid") from exc
|
cryptnode/cli.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""The public crnode command; private kernel names stay out of normal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
import unicodedata
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .diagnostics import doctor, runtime_state
|
|
17
|
+
from .lifecycle import _wait_host, cleanup, deploy, load, unload
|
|
18
|
+
from .models import NodeError, Profile
|
|
19
|
+
from .paths import active_root, profile_dir, profiles, select_profile
|
|
20
|
+
from .security import is_administrator, require_administrator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MUTATING = {"load", "unload", "cleanup", "start", "stop", "restart",
|
|
24
|
+
"enable", "disable", "addtask", "rmtask"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
28
|
+
def error(self, message):
|
|
29
|
+
raise NodeError("arguments", "参数不完整或无效:%s;请运行 crnode --help 查看用法" % message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = _ArgumentParser(prog="crnode", description="CryptNode 多门户客户端;门户参数使用名称或门户 ID,不是节点编号")
|
|
34
|
+
parser.add_argument("--version", action="store_true", help="显示版本")
|
|
35
|
+
parser.add_argument("--json", action="store_true", help="输出机器格式 JSON")
|
|
36
|
+
commands = parser.add_subparsers(dest="command", title="命令")
|
|
37
|
+
deploy_parser = commands.add_parser("deploy", help="初始化明确指定的安装目录")
|
|
38
|
+
deploy_parser.add_argument("install_dir", type=Path)
|
|
39
|
+
load_parser = commands.add_parser("load", help="加载 CryptNode 客户端钥匙包并保留源文件")
|
|
40
|
+
load_parser.add_argument("node_id", help="两位节点编号 01–99")
|
|
41
|
+
load_parser.add_argument("bundle", type=Path, help="钥匙包路径")
|
|
42
|
+
for name, help_text in (("unload", "卸载门户"), ("show", "查看门户"), ("start", "启动"),
|
|
43
|
+
("stop", "停止"), ("restart", "重启"), ("enable", "启用开机启动"),
|
|
44
|
+
("disable", "禁用开机启动"), ("addtask", "注册计划任务"), ("rmtask", "删除计划任务")):
|
|
45
|
+
item = commands.add_parser(name, help=help_text)
|
|
46
|
+
item.add_argument("portal", help="门户名称或门户 ID(不是节点编号)")
|
|
47
|
+
for name, help_text in (("list", "列出门户"), ("status", "查看状态"), ("doctor", "诊断")):
|
|
48
|
+
item = commands.add_parser(name, help=help_text)
|
|
49
|
+
if name != "list":
|
|
50
|
+
item.add_argument("portal", nargs="?", help="门户名称或门户 ID")
|
|
51
|
+
if name == "status":
|
|
52
|
+
item.add_argument("--verbose", action="store_true", help="显示详细状态")
|
|
53
|
+
commands.add_parser("pwd", help="显示已部署目录")
|
|
54
|
+
commands.add_parser("cleanup", help="停止并清理产品资料")
|
|
55
|
+
for item in commands.choices.values():
|
|
56
|
+
item.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="输出机器格式 JSON")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_LABELS = {"portal_name": "门户", "portal_id": "门户 ID", "node_id": "节点编号", "state": "状态",
|
|
61
|
+
"system_active": "系统运行", "system_enabled": "开机启动", "portal_configured": "云端已配置",
|
|
62
|
+
"portal_enabled": "云端已启用", "pair_id_short": "配对", "key_fingerprint": "证书指纹",
|
|
63
|
+
"created_at": "创建时间", "profile_path": "门户目录", "access_host": "接入主机",
|
|
64
|
+
"port_base": "起始端口", "runtime": "运行诊断", "issues": "诊断问题", "free_bytes": "磁盘可用字节",
|
|
65
|
+
"reason": "原因", "last": "上次状态", "checked_at": "检查时间", "log_warning": "日志写入告警",
|
|
66
|
+
"path": "安装目录", "message": "结果", "version": "版本"}
|
|
67
|
+
_VALUES = {"true": "是", "false": "否", "unknown": "未知", "access_limited": "权限受限",
|
|
68
|
+
"running": "运行中", "stopped": "已停止", "starting": "启动中", "stale": "状态回读已过期",
|
|
69
|
+
"suspended_by_portal": "云端暂停", "waiting_for_portal": "等待云端", "running_unknown": "运行中,云端状态未知",
|
|
70
|
+
"recovering": "恢复中", "failed": "失败", "task_missing": "计划任务不存在", "unit_missing": "系统实例不存在"}
|
|
71
|
+
_ISSUES = {"product log write failed; active tunnels were kept running": "产品日志写入失败;既有隧道保持运行",
|
|
72
|
+
"certificate content is restricted to the runtime administrator": "证书内容仅管理员可读;当前权限不足,未判定隧道故障",
|
|
73
|
+
"low disk space; retained logs will not be evicted early": "磁盘空间不足;保留期内日志不会提前删除",
|
|
74
|
+
"disk capacity could not be read": "无法读取磁盘容量",
|
|
75
|
+
"no current Host readback": "尚无当前运行状态回读", "invalid Host readback": "运行状态回读无效"}
|
|
76
|
+
_ERRORS = {
|
|
77
|
+
"install_dir": "安装目录必须是有效的绝对路径,不能是盘符根目录", "install_dir_access": "没有权限读取安装目录或目录指针",
|
|
78
|
+
"not_deployed": "尚未部署或部署资料无效;请执行 crnode deploy <安装目录>",
|
|
79
|
+
"portal_selection": "未找到唯一门户;请用门户名称或门户 ID(不是节点编号),可运行 crnode list 查看",
|
|
80
|
+
"node_id": "节点编号必须是两位数字 01–99", "portal_id": "门户 ID 必须在 1–99 之间",
|
|
81
|
+
"bundle_format": "钥匙包不是有效的 ZIP 文件", "bundle_members": "钥匙包文件集合不符合 CryptNode 格式",
|
|
82
|
+
"bundle_member": "钥匙包包含无效或不可读成员", "bundle_invalid": "钥匙包身份、摘要或证书校验失败",
|
|
83
|
+
"bundle_size": "钥匙包超过大小限制", "certificate_invalid": "客户端证书、证书链、私钥或有效期无效",
|
|
84
|
+
"peer_fingerprint": "门户服务端证书指纹不匹配或无效", "peer_tls": "门户 TLS 身份不可用或无效",
|
|
85
|
+
"task_missing": "计划任务不存在;请由管理员运行 crnode addtask <门户>",
|
|
86
|
+
"unit_missing": "系统运行实例不存在", "windows_only": "此命令仅支持 Windows",
|
|
87
|
+
"task_failed": "计划任务操作失败", "task_readback": "计划任务状态无法确认或与请求不符",
|
|
88
|
+
"path_link": "路径不是安全的普通文件或目录", "profile_invalid": "门户资料缺失或无效",
|
|
89
|
+
"profile_identity": "门户身份或证书摘要不一致", "portal_identity": "云端返回的门户身份不一致",
|
|
90
|
+
"profile_ambiguous": "存在重复门户名称", "portal_ambiguous": "门户名称属于另一门户",
|
|
91
|
+
"pair_conflict": "现有配对与钥匙包不一致", "anchor_in_use": "节点编号的管理锚点已被占用",
|
|
92
|
+
"node_unconfigured": "云端尚未配置该节点编号", "host_readback": "运行实例未达到安全状态",
|
|
93
|
+
"rollback_unconfirmed": "旧门户资料已恢复,但运行状态恢复失败;请检查门户状态",
|
|
94
|
+
"acl_failed": "无法设置产品资料访问权限", "file_unsafe": "私密文件缺失或权限不安全",
|
|
95
|
+
"tls_policy": "stunnel 配置偏离固定策略", "control_response": "云端控制响应无效",
|
|
96
|
+
"control_unavailable": "云端控制服务暂不可用", "runtime_python": "需要可信的 Python 运行环境",
|
|
97
|
+
"runtime_user": "需要可信的部署管理员身份", "port_conflict": "本地所需端口被占用",
|
|
98
|
+
"kernel_missing": "产品内核文件缺失", "kernel_start": "产品内核启动失败", "kernel_exit": "产品内核提前退出",
|
|
99
|
+
"kernel_timeout": "产品内核监听超时", "job_object": "无法建立或绑定受控进程树", "host_start": "运行实例启动失败",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _chinese(value):
|
|
104
|
+
if value is None:
|
|
105
|
+
return "未知"
|
|
106
|
+
if isinstance(value, bool):
|
|
107
|
+
return "是" if value else "否"
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
if value.startswith("Profile validation failed:"):
|
|
110
|
+
return "门户资料验证失败;请检查证书权限和配置"
|
|
111
|
+
return _VALUES.get(value, _ISSUES.get(value, value))
|
|
112
|
+
return str(value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _emit(value: Any, as_json: bool = False) -> None:
|
|
116
|
+
if as_json:
|
|
117
|
+
print(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")))
|
|
118
|
+
elif isinstance(value, list):
|
|
119
|
+
if not value:
|
|
120
|
+
print("暂无门户或诊断问题")
|
|
121
|
+
for row in value:
|
|
122
|
+
_emit(row)
|
|
123
|
+
elif isinstance(value, dict):
|
|
124
|
+
keys = [key for key in ("portal_name", "portal_id", "node_id") if key in value]
|
|
125
|
+
keys += [key for key in value if key not in keys]
|
|
126
|
+
for key in keys:
|
|
127
|
+
item = value[key]
|
|
128
|
+
print("%s:%s" % (_LABELS.get(key, key), "" if isinstance(item, (dict, list)) else
|
|
129
|
+
(_chinese(item)[:16] + "…" if key == "key_fingerprint" else _chinese(item))))
|
|
130
|
+
if isinstance(item, (dict, list)):
|
|
131
|
+
_emit(item)
|
|
132
|
+
else:
|
|
133
|
+
print(_chinese(value))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _display_width(value: str) -> int:
|
|
137
|
+
return sum(0 if unicodedata.combining(char) else
|
|
138
|
+
2 if unicodedata.east_asian_width(char) in ("F", "W") else 1
|
|
139
|
+
for char in value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _emit_list(rows: List[Dict[str, Any]], root: Path) -> None:
|
|
143
|
+
print("安装目录:%s" % root)
|
|
144
|
+
if not rows:
|
|
145
|
+
print("暂无门户")
|
|
146
|
+
return
|
|
147
|
+
headers = ("门户名称", "门户 ID", "节点 ID", "运行状态", "开机自启")
|
|
148
|
+
values = [tuple(str(row[key]) if key in ("portal_name", "portal_id", "node_id")
|
|
149
|
+
else _chinese(row.get(key))
|
|
150
|
+
for key in ("portal_name", "portal_id", "node_id", "state", "system_enabled"))
|
|
151
|
+
for row in rows]
|
|
152
|
+
widths = [max(_display_width(header), *(_display_width(row[index]) for row in values))
|
|
153
|
+
for index, header in enumerate(headers)]
|
|
154
|
+
|
|
155
|
+
def line(cells):
|
|
156
|
+
return " | ".join(cell + " " * (width - _display_width(cell))
|
|
157
|
+
for cell, width in zip(cells, widths)).rstrip()
|
|
158
|
+
|
|
159
|
+
print(line(headers))
|
|
160
|
+
print("-+-".join("-" * width for width in widths))
|
|
161
|
+
for row in values:
|
|
162
|
+
print(line(row))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _manager():
|
|
166
|
+
if os.name == "nt":
|
|
167
|
+
from . import windows
|
|
168
|
+
return windows
|
|
169
|
+
from . import ubuntu
|
|
170
|
+
return ubuntu
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _row(root: Path, profile: Profile) -> Dict[str, Any]:
|
|
174
|
+
manager = _manager()
|
|
175
|
+
try:
|
|
176
|
+
managed = manager.status(profile)
|
|
177
|
+
except (OSError, NodeError):
|
|
178
|
+
managed = {"exists": "unknown", "active": "unknown", "enabled": "unknown"}
|
|
179
|
+
runtime = runtime_state(root, profile)
|
|
180
|
+
state = ("task_missing" if os.name == "nt" else "unit_missing") if managed["exists"] == "false" else runtime["state"]
|
|
181
|
+
return {"portal_id": profile.portal_id, "portal_name": profile.portal_name,
|
|
182
|
+
"node_id": "%02d" % profile.node_id, "pair_id_short": profile.pair_id[:12],
|
|
183
|
+
"key_fingerprint": profile.own_cert_sha256, "created_at": profile.created_at,
|
|
184
|
+
"portal_configured": runtime.get("portal_configured"),
|
|
185
|
+
"portal_enabled": runtime.get("portal_enabled"),
|
|
186
|
+
"system_enabled": managed["enabled"], "system_active": managed["active"],
|
|
187
|
+
"state": state}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _clear_runtime_readback(root: Path, profile: Profile) -> None:
|
|
191
|
+
marker = profile_dir(root, profile.portal_id) / "runtime.json"
|
|
192
|
+
if marker.is_symlink():
|
|
193
|
+
raise NodeError("path_link", "Host readback cannot be a symbolic link")
|
|
194
|
+
marker.unlink(missing_ok=True)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _sudo_linux(arguments: List[str]) -> int:
|
|
198
|
+
"""Re-enter the same virtual environment in the current terminal."""
|
|
199
|
+
interpreter = Path(sys.executable).absolute()
|
|
200
|
+
if sys.prefix == sys.base_prefix or Path(sys.prefix).stat().st_uid != os.geteuid():
|
|
201
|
+
raise NodeError("runtime_python", "需要部署用户自有的可信虚拟环境")
|
|
202
|
+
if Path(sys.prefix).stat().st_mode & 0o002:
|
|
203
|
+
raise NodeError("runtime_python", "虚拟环境不能允许所有用户写入")
|
|
204
|
+
if shutil.which("sudo") is None:
|
|
205
|
+
raise NodeError("sudo_unavailable", "未找到 sudo;请安装并配置 sudo 后重试")
|
|
206
|
+
return subprocess.run(["sudo", "--", str(interpreter), "-m", "cryptnode", *arguments],
|
|
207
|
+
check=False).returncode
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _execute(args) -> Any:
|
|
211
|
+
command = args.command
|
|
212
|
+
if command == "deploy":
|
|
213
|
+
return {"path": str(deploy(args.install_dir)), "message": "部署完成"}
|
|
214
|
+
root = active_root()
|
|
215
|
+
if command == "pwd":
|
|
216
|
+
return {"path": str(root)}
|
|
217
|
+
if command == "load":
|
|
218
|
+
if not (len(args.node_id) == 2 and args.node_id.isdecimal()):
|
|
219
|
+
raise NodeError("node_id", "node_id must be a two-digit number 01..99")
|
|
220
|
+
profile = load(int(args.node_id), args.bundle, root)
|
|
221
|
+
return {"portal_name": profile.portal_name, "portal_id": profile.portal_id, "node_id": "%02d" % profile.node_id,
|
|
222
|
+
"state": runtime_state(root, profile)["state"]}
|
|
223
|
+
if command == "cleanup":
|
|
224
|
+
cleanup(root)
|
|
225
|
+
return {"message": "产品资料已清理"}
|
|
226
|
+
if command in ("list", "status", "doctor") and getattr(args, "portal", None) is None:
|
|
227
|
+
rows = tuple(profiles(root))
|
|
228
|
+
if command == "doctor":
|
|
229
|
+
return [dict(doctor(root, item), portal_name=item.portal_name) for item in rows]
|
|
230
|
+
return [_row(root, item) for item in rows]
|
|
231
|
+
profile = select_profile(root, args.portal)
|
|
232
|
+
if command == "show":
|
|
233
|
+
result = _row(root, profile)
|
|
234
|
+
result["profile_path"] = str(profile_dir(root, profile.portal_id))
|
|
235
|
+
result["access_host"] = profile.access_host
|
|
236
|
+
result["port_base"] = profile.port_base
|
|
237
|
+
return result
|
|
238
|
+
if command == "status":
|
|
239
|
+
return _row(root, profile)
|
|
240
|
+
if command == "doctor":
|
|
241
|
+
return dict(doctor(root, profile), portal_name=profile.portal_name)
|
|
242
|
+
if command == "unload":
|
|
243
|
+
unload(profile, root)
|
|
244
|
+
return {"portal_name": profile.portal_name, "portal_id": profile.portal_id, "node_id": "%02d" % profile.node_id, "message": "门户已卸载"}
|
|
245
|
+
manager = _manager()
|
|
246
|
+
if command == "addtask":
|
|
247
|
+
require_administrator()
|
|
248
|
+
if os.name != "nt":
|
|
249
|
+
raise NodeError("windows_only", "addtask is available only on Windows")
|
|
250
|
+
from .host import validate_profile
|
|
251
|
+
validate_profile(profile_dir(root, profile.portal_id), profile)
|
|
252
|
+
if manager.status(profile)["active"] != "true":
|
|
253
|
+
_clear_runtime_readback(root, profile)
|
|
254
|
+
manager.install(root, profile)
|
|
255
|
+
manager.perform(root, profile, "start")
|
|
256
|
+
_wait_host(profile_dir(root, profile.portal_id), profile)
|
|
257
|
+
return _row(root, profile)
|
|
258
|
+
if command == "rmtask":
|
|
259
|
+
require_administrator()
|
|
260
|
+
if os.name != "nt":
|
|
261
|
+
raise NodeError("windows_only", "rmtask is available only on Windows")
|
|
262
|
+
manager.remove(root, profile)
|
|
263
|
+
return _row(root, profile)
|
|
264
|
+
if command in ("start", "stop", "restart", "enable", "disable"):
|
|
265
|
+
managed = manager.status(profile)
|
|
266
|
+
if managed["exists"] != "true":
|
|
267
|
+
raise NodeError("task_missing" if os.name == "nt" else "unit_missing",
|
|
268
|
+
"CryptNode portal instance is missing")
|
|
269
|
+
if command == "start" and managed["active"] == "true":
|
|
270
|
+
return dict(_row(root, profile), message="实例已在运行")
|
|
271
|
+
require_administrator()
|
|
272
|
+
if os.name != "nt" and command in ("start", "restart"):
|
|
273
|
+
manager.reset_failed(profile)
|
|
274
|
+
if command == "restart" or (command == "start" and managed["active"] != "true"):
|
|
275
|
+
_clear_runtime_readback(root, profile)
|
|
276
|
+
manager.perform(root, profile, command)
|
|
277
|
+
if command in ("start", "restart"):
|
|
278
|
+
_wait_host(profile_dir(root, profile.portal_id), profile)
|
|
279
|
+
return _row(root, profile)
|
|
280
|
+
raise AssertionError("unreachable command")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main(argv=None) -> int:
|
|
284
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
285
|
+
as_json = "--json" in arguments
|
|
286
|
+
for stream in (sys.stdout, sys.stderr):
|
|
287
|
+
if hasattr(stream, "reconfigure"):
|
|
288
|
+
stream.reconfigure(encoding="utf-8")
|
|
289
|
+
try:
|
|
290
|
+
args = _parser().parse_args(arguments)
|
|
291
|
+
if args.version:
|
|
292
|
+
_emit({"version": __version__}, as_json)
|
|
293
|
+
return 0
|
|
294
|
+
if args.command is None:
|
|
295
|
+
raise NodeError("arguments", "请指定命令;运行 crnode --help 查看用法")
|
|
296
|
+
if args.command == "load":
|
|
297
|
+
args.bundle = Path(os.path.abspath(args.bundle))
|
|
298
|
+
if os.name != "nt" and not is_administrator() and (
|
|
299
|
+
args.command == "deploy" or args.command in MUTATING - {"start", "addtask", "rmtask"}):
|
|
300
|
+
return _sudo_linux(arguments)
|
|
301
|
+
if args.command in MUTATING and os.name == "nt":
|
|
302
|
+
require_administrator()
|
|
303
|
+
if os.name != "nt" and args.command == "start" and not is_administrator():
|
|
304
|
+
try:
|
|
305
|
+
result = _execute(args)
|
|
306
|
+
except NodeError as exc:
|
|
307
|
+
if exc.code != "administrator_required":
|
|
308
|
+
raise
|
|
309
|
+
return _sudo_linux(arguments)
|
|
310
|
+
else:
|
|
311
|
+
result = _execute(args)
|
|
312
|
+
if args.command == "list" and not as_json:
|
|
313
|
+
_emit_list(result, active_root())
|
|
314
|
+
else:
|
|
315
|
+
_emit(result, as_json)
|
|
316
|
+
return 0
|
|
317
|
+
except KeyboardInterrupt:
|
|
318
|
+
message = "操作已中断"
|
|
319
|
+
if as_json:
|
|
320
|
+
print(json.dumps({"code": "interrupted", "message": message}, ensure_ascii=False,
|
|
321
|
+
separators=(",", ":")), file=sys.stderr)
|
|
322
|
+
else:
|
|
323
|
+
print("crnode:%s [interrupted]" % message, file=sys.stderr)
|
|
324
|
+
return 130
|
|
325
|
+
except (NodeError, OSError, ValueError) as exc:
|
|
326
|
+
code = exc.code if isinstance(exc, NodeError) else "operation_failed"
|
|
327
|
+
message = str(exc)
|
|
328
|
+
if not any("\u4e00" <= char <= "\u9fff" for char in message):
|
|
329
|
+
message = _ERRORS.get(code, "配置校验失败" if code.startswith("config_") else "操作失败,请检查权限、路径及门户状态")
|
|
330
|
+
if as_json:
|
|
331
|
+
print(json.dumps({"code": code, "message": message}, ensure_ascii=False, separators=(",", ":")), file=sys.stderr)
|
|
332
|
+
else:
|
|
333
|
+
print("crnode:%s [%s]" % (message, code), file=sys.stderr)
|
|
334
|
+
return 78
|
cryptnode/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Validate the native, byte-preserved FRPC configuration from CryptPortal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, Set
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ImportError:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
from .models import NodeError, Profile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_TEMPLATE = re.compile(r"\$\{|\{\{|\}\}|\.Envs|<%")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _walk(value: Any) -> None:
|
|
20
|
+
if isinstance(value, dict):
|
|
21
|
+
for key, child in value.items():
|
|
22
|
+
if not isinstance(key, str) or _TEMPLATE.search(key) or key == "includes":
|
|
23
|
+
raise NodeError("config_invalid", "FRPC configuration contains a dynamic field")
|
|
24
|
+
_walk(child)
|
|
25
|
+
elif isinstance(value, list):
|
|
26
|
+
for child in value:
|
|
27
|
+
_walk(child)
|
|
28
|
+
elif isinstance(value, str) and _TEMPLATE.search(value):
|
|
29
|
+
raise NodeError("config_invalid", "FRPC configuration contains a template")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_frpc_document(data: bytes, profile: Profile) -> Dict[str, Any]:
|
|
33
|
+
if len(data) > 2 * 1024 * 1024:
|
|
34
|
+
raise NodeError("config_size", "node.toml exceeds the size limit")
|
|
35
|
+
try:
|
|
36
|
+
root = tomllib.loads(data.decode("utf-8"))
|
|
37
|
+
except (UnicodeError, ValueError, tomllib.TOMLDecodeError):
|
|
38
|
+
raise NodeError("config_invalid", "node.toml is not UTF-8 TOML") from None
|
|
39
|
+
_walk(root)
|
|
40
|
+
addr = profile.local_addr
|
|
41
|
+
if root.get("serverAddr") != addr or root.get("serverPort") != 13201:
|
|
42
|
+
raise NodeError("config_endpoint", "FRPC data endpoint differs from the portal loopback address")
|
|
43
|
+
auth = root.get("auth")
|
|
44
|
+
source = auth.get("tokenSource") if isinstance(auth, dict) else None
|
|
45
|
+
token_file = source.get("file") if isinstance(source, dict) else None
|
|
46
|
+
if (not isinstance(auth, dict) or auth.get("method") != "token"
|
|
47
|
+
or not isinstance(source, dict) or source.get("type") != "file"
|
|
48
|
+
or not isinstance(token_file, dict) or token_file.get("path") != "certs/frp-token"):
|
|
49
|
+
raise NodeError("config_auth", "FRPC must read the current pair token from certs")
|
|
50
|
+
web = root.get("webServer")
|
|
51
|
+
if (not isinstance(web, dict) or web.get("addr") != addr
|
|
52
|
+
or type(web.get("port")) is not int or web["port"] != 13202
|
|
53
|
+
or "user" in web or "password" in web):
|
|
54
|
+
raise NodeError("config_management", "FRPC management must use the portal loopback endpoint")
|
|
55
|
+
proxies = root.get("proxies")
|
|
56
|
+
if not isinstance(proxies, list):
|
|
57
|
+
raise NodeError("config_proxies", "FRPC proxies must be explicit tables")
|
|
58
|
+
names: Set[str] = set()
|
|
59
|
+
ports: Set[int] = set()
|
|
60
|
+
anchors = []
|
|
61
|
+
for item in proxies:
|
|
62
|
+
if not isinstance(item, dict):
|
|
63
|
+
raise NodeError("config_proxy", "invalid FRPC proxy")
|
|
64
|
+
name = item.get("name")
|
|
65
|
+
port = item.get("remotePort")
|
|
66
|
+
if (not isinstance(name, str) or not name or name != name.strip()
|
|
67
|
+
or name in names or item.get("type") != "tcp"
|
|
68
|
+
or type(port) is not int or not 20000 <= port <= 29999
|
|
69
|
+
or port in ports or "loadBalancer" in item):
|
|
70
|
+
raise NodeError("config_proxy", "FRPC proxy name, type or port is invalid")
|
|
71
|
+
names.add(name)
|
|
72
|
+
ports.add(port)
|
|
73
|
+
if port == profile.anchor_port:
|
|
74
|
+
anchors.append(item)
|
|
75
|
+
if (len(anchors) != 1 or anchors[0].get("localIP") != addr
|
|
76
|
+
or anchors[0].get("localPort") != 13202):
|
|
77
|
+
raise NodeError("config_anchor", "hN/00 must target local FRPC management")
|
|
78
|
+
return root
|
|
79
|
+
|