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/control.py ADDED
@@ -0,0 +1,66 @@
1
+ """Bounded requests to the CryptPortal role-separated control channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict
7
+ from urllib import request
8
+
9
+ from .models import NodeError, Profile
10
+
11
+
12
+ SCHEMA = "cryptportal.control/v1"
13
+ MAX_RESPONSE = 2 * 1024 * 1024
14
+
15
+
16
+ def _get(profile: Profile, path: str) -> bytes:
17
+ origin = "http://%s:13200" % profile.local_addr
18
+ call = request.Request(origin + path, headers={"Host": "%s:13200" % profile.local_addr}, method="GET")
19
+ try:
20
+ opener = request.build_opener(request.ProxyHandler({}))
21
+ with opener.open(call, timeout=5) as response:
22
+ data = response.read(MAX_RESPONSE + 1)
23
+ if response.status != 200 or len(data) > MAX_RESPONSE:
24
+ raise NodeError("control_response", "portal control response is invalid")
25
+ return data
26
+ except (OSError, ValueError) as exc:
27
+ raise NodeError("control_unavailable", "portal control channel is unavailable") from exc
28
+
29
+
30
+ def _json(profile: Profile, path: str) -> Dict[str, Any]:
31
+ try:
32
+ value = json.loads(_get(profile, path).decode("utf-8"))
33
+ except (UnicodeError, ValueError):
34
+ raise NodeError("control_response", "portal control JSON is invalid") from None
35
+ if not isinstance(value, dict) or value.get("schema") != SCHEMA:
36
+ raise NodeError("control_response", "portal control schema is invalid")
37
+ return value
38
+
39
+
40
+ def check_identity(profile: Profile) -> Dict[str, Any]:
41
+ info = _json(profile, "/v1/info")
42
+ if (info.get("portal_id") != profile.portal_id
43
+ or info.get("portal_name") != profile.portal_name
44
+ or info.get("role") != "cryptnode" or info.get("pair_id") != profile.pair_id):
45
+ raise NodeError("portal_identity", "portal identity or current key pair differs")
46
+ return info
47
+
48
+
49
+ def node_state(profile: Profile) -> Dict[str, Any]:
50
+ check_identity(profile)
51
+ state = _json(profile, "/v1/nodes/%02d" % profile.node_id)
52
+ if (type(state.get("configured")) is not bool or type(state.get("enabled")) is not bool
53
+ or state.get("anchor_port") != profile.anchor_port
54
+ or type(state.get("anchor_in_use")) is not bool
55
+ or not isinstance(state.get("config_etag"), str)):
56
+ raise NodeError("control_response", "portal node policy is invalid")
57
+ return state
58
+
59
+
60
+ def node_config(profile: Profile) -> bytes:
61
+ check_identity(profile)
62
+ from .config import validate_frpc_document
63
+ data = _get(profile, "/v1/nodes/%02d/config" % profile.node_id)
64
+ validate_frpc_document(data, profile)
65
+ return data
66
+
@@ -0,0 +1,53 @@
1
+ """Read-only, credential-free product diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import shutil
8
+ import time
9
+ from typing import Any, Dict
10
+
11
+ from .models import Profile
12
+ from .paths import profile_dir
13
+
14
+
15
+ def runtime_state(root: Path, profile: Profile) -> Dict[str, Any]:
16
+ path = profile_dir(root, profile.portal_id) / "runtime.json"
17
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 8192:
18
+ return {"state": "unknown", "reason": "no current Host readback"}
19
+ try:
20
+ row = json.loads(path.read_text(encoding="utf-8"))
21
+ if row.get("portal_id") != profile.portal_id or row.get("node_id") != profile.node_id:
22
+ raise ValueError("identity")
23
+ if row.get("checked_at", 0) < time.time() - 300:
24
+ return {"state": "stale", "last": row.get("state", "unknown")}
25
+ return {"state": row.get("state", "unknown"), "reason": row.get("error", ""),
26
+ "checked_at": row.get("checked_at"), "log_warning": row.get("log_warning", False),
27
+ "portal_configured": row.get("portal_configured"),
28
+ "portal_enabled": row.get("portal_enabled")}
29
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
30
+ return {"state": "unknown", "reason": "invalid Host readback"}
31
+
32
+
33
+ def doctor(root: Path, profile: Profile) -> Dict[str, Any]:
34
+ directory = profile_dir(root, profile.portal_id)
35
+ result: Dict[str, Any] = {"portal_id": profile.portal_id, "node_id": profile.node_id,
36
+ "runtime": runtime_state(root, profile), "issues": []}
37
+ if result["runtime"].get("log_warning"):
38
+ result["issues"].append("product log write failed; active tunnels were kept running")
39
+ try:
40
+ from .host import validate_profile
41
+ validate_profile(directory, profile)
42
+ except PermissionError:
43
+ result["issues"].append("certificate content is restricted to the runtime administrator")
44
+ except Exception as exc:
45
+ result["issues"].append("Profile validation failed: %s" % type(exc).__name__)
46
+ try:
47
+ free = shutil.disk_usage(str(root)).free
48
+ result["free_bytes"] = free
49
+ if free < 512 * 1024 * 1024:
50
+ result["issues"].append("low disk space; retained logs will not be evicted early")
51
+ except OSError:
52
+ result["issues"].append("disk capacity could not be read")
53
+ return result
cryptnode/frp.py ADDED
@@ -0,0 +1,18 @@
1
+ """Private FRP client launch and exact configuration preflight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .config import validate_frpc_document
8
+ from .models import Profile
9
+ from .processes import Child, resource_binary
10
+ from .security import assert_private_file
11
+
12
+
13
+ def start_core(directory: Path, profile: Profile) -> Child:
14
+ config_path = directory / "node.toml"
15
+ assert_private_file(directory / "certs" / "frp-token")
16
+ validate_frpc_document(config_path.read_bytes(), profile)
17
+ return Child([str(resource_binary("crnode-core")), "-c", str(config_path)], directory, "core")
18
+
cryptnode/host.py ADDED
@@ -0,0 +1,202 @@
1
+ """One long-lived supervisor per Portal Profile, with no mapping-sync loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import signal
9
+ import sys
10
+ import time
11
+ from typing import Optional
12
+
13
+ from .certificates import validate_client_cert
14
+ from .config import validate_frpc_document
15
+ from .control import node_config, node_state
16
+ from .frp import start_core
17
+ from .logs import prune_expired
18
+ from .models import NodeError, Profile
19
+ from .paths import profile_dir, read_profile
20
+ from .processes import Child, require_ports
21
+ from .security import assert_private_file, atomic_write
22
+ from .tls import render_policy, start_tunnel
23
+
24
+
25
+ def validate_profile(directory: Path, profile: Profile) -> None:
26
+ certs = directory / "certs"
27
+ for name in ("cert.pem", "key.pem", "ca.pem", "frp-token"):
28
+ assert_private_file(certs / name)
29
+ validate_client_cert((certs / "cert.pem").read_bytes(), (certs / "key.pem").read_bytes(),
30
+ (certs / "ca.pem").read_bytes(), profile.created_at)
31
+ from .certificates import certificate_sha256
32
+ if certificate_sha256((certs / "cert.pem").read_bytes()) != profile.own_cert_sha256:
33
+ raise NodeError("profile_identity", "Profile certificate fingerprint differs")
34
+ for duty in ("control", "data"):
35
+ path = directory / (duty + ".conf")
36
+ if path.is_symlink() or path.read_text(encoding="utf-8") != render_policy(profile, duty):
37
+ raise NodeError("tls_policy", "Profile stunnel policy differs")
38
+ config = directory / "node.toml"
39
+ if config.exists():
40
+ if config.is_symlink():
41
+ raise NodeError("config_invalid", "node.toml cannot be a symbolic link")
42
+ validate_frpc_document(config.read_bytes(), profile)
43
+
44
+
45
+ class PortalHost:
46
+ def __init__(self, directory: Path, profile: Profile):
47
+ self.directory = directory
48
+ self.profile = profile
49
+ self.control: Optional[Child] = None
50
+ self.data: Optional[Child] = None
51
+ self.core: Optional[Child] = None
52
+ self.stopping = False
53
+ self.state = "starting"
54
+ self.error = ""
55
+ self.last_check = 0.0
56
+ self.next_check = 0.0
57
+ self.failures = 0
58
+ self.log_warning = False
59
+ self.portal_configured = None
60
+ self.portal_enabled = None
61
+
62
+ def _record(self) -> None:
63
+ payload = {"portal_id": self.profile.portal_id, "node_id": self.profile.node_id,
64
+ "state": self.state, "error": self.error,
65
+ "log_warning": self.log_warning or any(
66
+ child.log_error for child in (self.control, self.data, self.core) if child is not None),
67
+ "portal_configured": self.portal_configured,
68
+ "portal_enabled": self.portal_enabled,
69
+ "control_pid": self.control.process.pid if self.control and self.control.poll() is None else None,
70
+ "data_pid": self.data.process.pid if self.data and self.data.poll() is None else None,
71
+ "core_pid": self.core.process.pid if self.core and self.core.poll() is None else None,
72
+ "checked_at": int(time.time())}
73
+ try:
74
+ atomic_write(self.directory / "runtime.json",
75
+ (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8"),
76
+ public_read=True, parent_public_read=True)
77
+ except (OSError, NodeError):
78
+ # A full disk or broken log/state destination must not restart a live tunnel.
79
+ pass
80
+
81
+ def _stop_data(self) -> None:
82
+ for attr in ("core", "data"):
83
+ child = getattr(self, attr)
84
+ if child is not None:
85
+ self.log_warning = self.log_warning or child.log_error
86
+ child.stop()
87
+ setattr(self, attr, None)
88
+
89
+ def _start_data(self) -> None:
90
+ require_ports(self.profile, (13201, 13202))
91
+ config = node_config(self.profile)
92
+ path = self.directory / "node.toml"
93
+ previous = path.read_bytes() if path.is_file() and not path.is_symlink() else None
94
+ atomic_write(path, config, parent_public_read=True)
95
+ try:
96
+ self.data = start_tunnel(self.directory, self.profile, "data")
97
+ self.core = start_core(self.directory, self.profile)
98
+ except Exception:
99
+ self._stop_data()
100
+ if previous is None:
101
+ path.unlink(missing_ok=True)
102
+ else:
103
+ atomic_write(path, previous, parent_public_read=True)
104
+ raise
105
+
106
+ def _probe(self) -> None:
107
+ state = node_state(self.profile)
108
+ self.portal_configured = state["configured"]
109
+ self.portal_enabled = state["enabled"]
110
+ if not state["configured"] or not state["enabled"]:
111
+ self._stop_data()
112
+ self.state = "suspended_by_portal"
113
+ self.error = "unconfigured" if not state["configured"] else "disabled"
114
+ return
115
+ if self.core is None:
116
+ if state["anchor_in_use"]:
117
+ raise NodeError("anchor_in_use", "hN/00 is occupied before startup")
118
+ validate_profile(self.directory, self.profile)
119
+ self._start_data()
120
+ self.state = "running"
121
+ self.error = ""
122
+
123
+ def run(self) -> int:
124
+ validate_profile(self.directory, self.profile)
125
+ require_ports(self.profile, (13200,))
126
+ self.control = start_tunnel(self.directory, self.profile, "control")
127
+ try:
128
+ while not self.stopping:
129
+ if (self.directory / "stop-request").exists():
130
+ self.stopping = True
131
+ break
132
+ now = time.monotonic()
133
+ if self.control.poll() is not None:
134
+ self._stop_data()
135
+ raise NodeError("control_exit", "control tunnel exited")
136
+ if self.core is not None and self.core.poll() is not None:
137
+ self._stop_data()
138
+ self.state = "recovering"
139
+ self.error = "private core exited"
140
+ self.failures += 1
141
+ self.next_check = now + min(300, 5 * (2 ** min(self.failures, 6)))
142
+ if self.data is not None and self.data.poll() is not None:
143
+ self._stop_data()
144
+ self.state = "recovering"
145
+ self.error = "data tunnel exited"
146
+ self.failures += 1
147
+ self.next_check = now + min(300, 5 * (2 ** min(self.failures, 6)))
148
+ if now >= self.next_check:
149
+ try:
150
+ self._probe()
151
+ self.failures = 0
152
+ self.next_check = now + 120
153
+ except NodeError as exc:
154
+ # An unknown control state must never tear down a live FRP session.
155
+ self.error = exc.code
156
+ self.state = "running_unknown" if self.core is not None else "waiting_for_portal"
157
+ self.failures += 1
158
+ self.next_check = now + min(300, 5 * (2 ** min(self.failures, 6)))
159
+ self.last_check = now
160
+ prune_expired(self.directory / "logs")
161
+ self._record()
162
+ time.sleep(0.25)
163
+ finally:
164
+ self._stop_data()
165
+ if self.control is not None:
166
+ self.log_warning = self.log_warning or self.control.log_error
167
+ self.control.stop()
168
+ self.control = None
169
+ self.state = "stopped"
170
+ self._record()
171
+ return 0
172
+
173
+
174
+ def main(argv=None) -> int:
175
+ parser = argparse.ArgumentParser(prog="crnode-host")
176
+ parser.add_argument("--portal-id", type=int, required=True)
177
+ parser.add_argument("--install-dir", type=Path, required=True)
178
+ parser.add_argument("--validate", action="store_true")
179
+ args = parser.parse_args(argv)
180
+ directory = profile_dir(args.install_dir, args.portal_id)
181
+ profile = read_profile(directory)
182
+ if args.validate:
183
+ try:
184
+ validate_profile(directory, profile)
185
+ return 0
186
+ except (OSError, NodeError) as exc:
187
+ print("crnode-host: %s" % exc, file=sys.stderr)
188
+ return 78
189
+ host = PortalHost(directory, profile)
190
+ def stop(_number, _frame):
191
+ host.stopping = True
192
+ signal.signal(signal.SIGTERM, stop)
193
+ signal.signal(signal.SIGINT, stop)
194
+ try:
195
+ return host.run()
196
+ except (OSError, NodeError) as exc:
197
+ print("crnode-host: %s" % exc, file=sys.stderr)
198
+ return 78
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())
cryptnode/lifecycle.py ADDED
@@ -0,0 +1,307 @@
1
+ """Deploy, load/rotate, unload and cleanup with an exact Profile preimage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import sys
10
+ import time
11
+ import uuid
12
+ import io
13
+ import zipfile
14
+ from typing import Optional
15
+
16
+ from .bundles import MAX_ARCHIVE, MAX_MEMBER, tomllib, validate_bundle
17
+ from .control import node_config, node_state
18
+ from .models import ClientBundle, NodeError, Profile
19
+ from .paths import (active_root, checked_absolute, default_root, pointer_path,
20
+ profile_dir, profile_json, profiles)
21
+ from .security import atomic_write, require_administrator, secure_directory, trusted_runtime_account
22
+ from .tls import render_policy, start_tunnel
23
+
24
+
25
+ def _manager():
26
+ if os.name == "nt":
27
+ from . import windows
28
+ return windows
29
+ from . import ubuntu
30
+ return ubuntu
31
+
32
+
33
+ def _runtime_identity():
34
+ if os.name == "nt":
35
+ return None
36
+ return trusted_runtime_account()
37
+
38
+
39
+ def _grant_owner(path: Path, account) -> None:
40
+ if account is not None:
41
+ os.chown(path, account.pw_uid, account.pw_gid)
42
+
43
+
44
+ def _grant_tree(path: Path, account) -> None:
45
+ if account is None:
46
+ return
47
+ for directory, subdirs, filenames in os.walk(path, followlinks=False):
48
+ current = Path(directory)
49
+ if current.is_symlink():
50
+ raise NodeError("path_link", "Profile contains a symbolic link")
51
+ _grant_owner(current, account)
52
+ current.chmod(0o700)
53
+ for name in filenames:
54
+ item = current / name
55
+ if item.is_symlink():
56
+ raise NodeError("path_link", "Profile contains a symbolic link")
57
+ _grant_owner(item, account)
58
+ item.chmod(0o600 if current.name == "certs" else 0o640)
59
+
60
+
61
+ def _installation(root: Path) -> dict:
62
+ marker = root / "installation.json"
63
+ if marker.is_symlink() or not marker.is_file():
64
+ raise NodeError("not_deployed", "CryptNode has not been deployed")
65
+ try:
66
+ value = json.loads(marker.read_text(encoding="utf-8"))
67
+ if value.get("schema") != "cryptnode.installation/v1" or value.get("root") != str(root):
68
+ raise ValueError("installation")
69
+ return value
70
+ except (OSError, TypeError, ValueError):
71
+ raise NodeError("not_deployed", "CryptNode installation marker is invalid") from None
72
+
73
+
74
+ def deploy(install_dir: Optional[Path] = None) -> Path:
75
+ if install_dir is None:
76
+ raise NodeError("install_dir", "请显式指定安装目录:crnode deploy <安装目录>")
77
+ if os.name != "nt":
78
+ require_administrator()
79
+ account = _runtime_identity()
80
+ root = checked_absolute(install_dir)
81
+ pointer = pointer_path()
82
+ if pointer.exists() or pointer.is_symlink():
83
+ if pointer.is_symlink() or not pointer.is_file():
84
+ raise NodeError("path_link", "安装目录指针不是普通文件")
85
+ previous = checked_absolute(Path(pointer.read_text(encoding="utf-8").strip()))
86
+ if previous != root:
87
+ raise NodeError("already_deployed", "已经部署到 %s;不能切换安装目录" % previous)
88
+ _installation(root)
89
+ if not (root / "portals").is_dir():
90
+ raise NodeError("not_deployed", "已部署目录不完整")
91
+ return root
92
+ if root.exists() and not (root / "installation.json").exists() and any(root.iterdir()):
93
+ raise NodeError("install_dir", "安装目录包含其他文件")
94
+ if (root / "installation.json").exists():
95
+ _installation(root)
96
+ marker = {"schema": "cryptnode.installation/v1", "root": str(root),
97
+ "python": str(Path(sys.executable).absolute())}
98
+ # No secrets or SYSTEM task are created by deploy. ACL protection starts at load.
99
+ root.mkdir(parents=True, exist_ok=True)
100
+ (root / "portals").mkdir(exist_ok=True)
101
+ (root / "installation.json").write_text(
102
+ json.dumps(marker, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
103
+ checked_absolute(pointer)
104
+ pointer.parent.mkdir(parents=True, exist_ok=True)
105
+ pointer.write_text(str(root) + "\n", encoding="utf-8")
106
+ _grant_owner(root, account)
107
+ _grant_owner(root / "portals", account)
108
+ if os.name != "nt":
109
+ pointer.parent.chmod(0o755)
110
+ pointer.chmod(0o644)
111
+ return root
112
+
113
+
114
+ def _read_zip(path: Path) -> ClientBundle:
115
+ try:
116
+ if not path.exists():
117
+ raise NodeError("bundle_missing", "钥匙包路径不存在:%s" % path)
118
+ if path.is_symlink() or not path.is_file():
119
+ raise NodeError("bundle_path", "钥匙包路径不是文件:%s" % path)
120
+ if path.stat().st_size > MAX_ARCHIVE:
121
+ raise NodeError("bundle_size", "钥匙包超过大小限制")
122
+ data = path.read_bytes()
123
+ except PermissionError:
124
+ raise NodeError("bundle_access", "没有权限读取钥匙包:%s" % path) from None
125
+ except FileNotFoundError:
126
+ raise NodeError("bundle_missing", "钥匙包路径不存在:%s" % path) from None
127
+ # Identify an incompatible role before the strict member-set parser, without
128
+ # extracting files or relaxing any bundle validation.
129
+ try:
130
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
131
+ info = archive.getinfo("manifest.toml")
132
+ if info.file_size <= MAX_MEMBER:
133
+ manifest = tomllib.loads(archive.read(info).decode("utf-8"))
134
+ if manifest.get("role") not in (None, "cryptnode") or manifest.get("side") not in (None, "client"):
135
+ raise NodeError("bundle_role", "请选择 CryptNode 客户端钥匙包;不能使用 CryptLink 或服务端钥匙包")
136
+ except (KeyError, ValueError, UnicodeError, zipfile.BadZipFile, RuntimeError):
137
+ pass
138
+ return validate_bundle(data)
139
+
140
+
141
+ def _candidate(bundle: ClientBundle, node_id: int) -> Profile:
142
+ identity = bundle.identity
143
+ if type(node_id) is not int or not 1 <= node_id <= 99:
144
+ raise NodeError("node_id", "node_id must be 01..99")
145
+ return Profile(identity.portal_id, identity.portal_name, node_id, identity.access_host,
146
+ identity.port_base, identity.pair_id, identity.created_at,
147
+ identity.own_cert_sha256, identity.peer_cert_sha256)
148
+
149
+
150
+ def _stage_profile(stage: Path, profile: Profile, bundle: ClientBundle, account) -> None:
151
+ secure_directory(stage, public_read=True)
152
+ secure_directory(stage / "certs")
153
+ secure_directory(stage / "logs")
154
+ for name in ("cert.pem", "key.pem", "ca.pem", "frp-token"):
155
+ atomic_write(stage / "certs" / name, bundle.files[name], secret=True)
156
+ atomic_write(stage / "portal.json", profile_json(profile),
157
+ public_read=True, parent_public_read=True)
158
+ for duty in ("control", "data"):
159
+ atomic_write(stage / (duty + ".conf"), render_policy(profile, duty).encode("utf-8"),
160
+ parent_public_read=True)
161
+ _grant_tree(stage, account)
162
+
163
+
164
+ def _get_initial_config(stage: Path, profile: Profile) -> tuple:
165
+ from .processes import require_ports
166
+ require_ports(profile, (13200, 13201, 13202))
167
+ tunnel = start_tunnel(stage, profile, "control")
168
+ try:
169
+ state = node_state(profile)
170
+ if not state["configured"]:
171
+ raise NodeError("node_unconfigured", "node_id is not preconfigured in the portal")
172
+ if state["anchor_in_use"]:
173
+ raise NodeError("anchor_in_use", "hN/00 is occupied before load")
174
+ config = node_config(profile)
175
+ return state, config
176
+ finally:
177
+ tunnel.stop()
178
+
179
+
180
+ def _wait_host(directory: Path, profile: Profile, *, timeout: float = 30.0) -> str:
181
+ deadline = time.monotonic() + timeout
182
+ marker = directory / "runtime.json"
183
+ while time.monotonic() < deadline:
184
+ if marker.is_file() and not marker.is_symlink():
185
+ try:
186
+ row = json.loads(marker.read_text(encoding="utf-8"))
187
+ if (row.get("portal_id") == profile.portal_id
188
+ and row.get("node_id") == profile.node_id
189
+ and row.get("checked_at", 0) >= time.time() - timeout - 5
190
+ and row.get("state") in ("running", "suspended_by_portal")):
191
+ return row["state"]
192
+ except (OSError, TypeError, ValueError):
193
+ pass
194
+ time.sleep(0.25)
195
+ raise NodeError("host_readback", "等待运行实例的新安全状态超时;请运行 crnode doctor <门户> 查看原因")
196
+
197
+
198
+ def load(node_id: int, bundle_path: Path, root: Optional[Path] = None) -> Profile:
199
+ require_administrator()
200
+ account = _runtime_identity()
201
+ root = checked_absolute(root or active_root())
202
+ _installation(root)
203
+ bundle_path = Path(os.path.abspath(bundle_path))
204
+ bundle = _read_zip(bundle_path)
205
+ profile = _candidate(bundle, node_id)
206
+ others = tuple(profiles(root))
207
+ current = next((item for item in others if item.portal_id == profile.portal_id), None)
208
+ if any(item.portal_name == profile.portal_name and item.portal_id != profile.portal_id for item in others):
209
+ raise NodeError("portal_ambiguous", "portal name belongs to another Profile")
210
+ if current is not None:
211
+ if current.node_id != node_id or current.portal_name != profile.portal_name:
212
+ raise NodeError("profile_identity", "rotation cannot change node_id or portal name")
213
+ if current.pair_id == profile.pair_id:
214
+ certs = profile_dir(root, profile.portal_id) / "certs"
215
+ if current != profile or any(
216
+ (certs / name).is_symlink() or not (certs / name).is_file()
217
+ or (certs / name).read_bytes() != bundle.files[name]
218
+ for name in ("cert.pem", "key.pem", "ca.pem", "frp-token")):
219
+ raise NodeError("pair_conflict", "current pair_id differs from client.zip")
220
+ return current
221
+ secure_directory(root, public_read=True)
222
+ secure_directory(root / "portals", public_read=True)
223
+ secure_directory(root / ".staging")
224
+ manager = _manager()
225
+ target = profile_dir(root, profile.portal_id)
226
+ staging = root / ".staging"
227
+ nonce = uuid.uuid4().hex
228
+ stage = staging / nonce / target.name
229
+ backup = staging / ("backup-" + nonce) / target.name
230
+ old_active = False
231
+ committed = False
232
+ try:
233
+ if current is not None:
234
+ old_active = manager.status(current)["active"] == "true"
235
+ manager.perform(root, current, "stop")
236
+ _stage_profile(stage, profile, bundle, account)
237
+ if current is not None:
238
+ previous_logs = target / "logs"
239
+ if previous_logs.is_symlink():
240
+ raise NodeError("path_link", "old portal logs are linked")
241
+ if previous_logs.is_dir():
242
+ for entry in previous_logs.iterdir():
243
+ if entry.is_symlink() or not entry.is_file():
244
+ raise NodeError("path_link", "old portal log entry is unsafe")
245
+ shutil.copy2(entry, stage / "logs" / entry.name)
246
+ _state, config = _get_initial_config(stage, profile)
247
+ atomic_write(stage / "node.toml", config, parent_public_read=True)
248
+ _grant_tree(stage, account)
249
+ if current is not None:
250
+ secure_directory(backup.parent)
251
+ target.rename(backup)
252
+ stage.rename(target)
253
+ committed = True
254
+ manager.install(root, profile)
255
+ manager.perform(root, profile, "start")
256
+ _wait_host(target, profile)
257
+ if current is not None:
258
+ shutil.rmtree(backup)
259
+ return profile
260
+ except Exception:
261
+ if committed:
262
+ try:
263
+ manager.remove(root, profile)
264
+ except Exception:
265
+ pass
266
+ if target.exists():
267
+ shutil.rmtree(target)
268
+ if current is not None and backup.exists():
269
+ backup.rename(target)
270
+ try:
271
+ manager.install(root, current)
272
+ if old_active:
273
+ manager.perform(root, current, "start")
274
+ except Exception as exc:
275
+ raise NodeError("rollback_unconfirmed", "old Portal Profile restored but service recovery failed") from exc
276
+ elif current is not None and old_active:
277
+ manager.perform(root, current, "start")
278
+ raise
279
+ finally:
280
+ for parent in (stage.parent, backup.parent):
281
+ if parent.exists():
282
+ shutil.rmtree(parent)
283
+
284
+
285
+ def unload(profile: Profile, root: Optional[Path] = None) -> None:
286
+ require_administrator()
287
+ root = checked_absolute(root or active_root())
288
+ _installation(root)
289
+ target = profile_dir(root, profile.portal_id)
290
+ if not target.is_dir() or target.is_symlink():
291
+ raise NodeError("profile_invalid", "Portal Profile path is unsafe")
292
+ _manager().remove(root, profile)
293
+ shutil.rmtree(target)
294
+
295
+
296
+ def cleanup(root: Optional[Path] = None) -> None:
297
+ require_administrator()
298
+ root = checked_absolute(root or active_root())
299
+ _installation(root)
300
+ for profile in tuple(profiles(root)):
301
+ unload(profile, root)
302
+ if root.is_symlink():
303
+ raise NodeError("path_link", "CryptNode install_dir is a symbolic link")
304
+ shutil.rmtree(root)
305
+ pointer = pointer_path()
306
+ if pointer.is_file() and pointer.read_text(encoding="utf-8").strip() == str(root):
307
+ pointer.unlink()