wimotek-edge-agent 1.0.5 → 1.0.7

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wimotek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MANIFEST.in ADDED
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include LICENSE
3
+ include pyproject.toml
4
+ include setup.py
5
+ recursive-include wimotek_edge_agent *.py py.typed
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- # edge_agent commercial module
1
+ # Wimotek Edge Agent
2
2
 
3
- Commercial-grade edge agent with explicit access policy, TOFU quarantine,
4
- Edge Agent token verification, session fast re-auth, and NAT4 reverse tunnel.
3
+ Commercial-grade edge access control, service bridge, and tunnel agent featuring a 3-layer decision policy engine, TOFU quarantine, single-use HMAC token verification, session fast re-auth, and NAT4 reverse tunneling.
5
4
 
6
5
  ## Table of contents
7
6
 
8
7
  - [Design goals](#design-goals)
9
8
  - [Architecture](#architecture)
10
9
  - [Decision matrix](#decision-matrix)
11
- - [Install](#install)
10
+ - [Installation & Quickstart](#installation--quickstart)
11
+ - [PyPI 安装与 ModuleNotFoundError 深度排查](#pypi-安装与-modulenotfounderror-深度排查)
12
12
  - [Environment variables](#environment-variables)
13
13
  - [CLI usage](#cli-usage)
14
14
  - [Programmatic usage](#programmatic-usage)
@@ -16,52 +16,44 @@ Edge Agent token verification, session fast re-auth, and NAT4 reverse tunnel.
16
16
  - [Audit log](#audit-log)
17
17
  - [Security model](#security-model)
18
18
  - [Commercial deployment checklist](#commercial-deployment-checklist)
19
- - [Publishing](#publishing)
20
19
  - [License](#license)
21
20
 
22
21
  ## Design goals
23
22
 
24
- 1. **Local connections default allow**, gated by a `local_bypass_token`
25
- written with mode `0600`, so other processes on the same host cannot
26
- trivially reuse `127.0.0.1`.
27
- 2. **External connections default TOFU**. A node without a token is
28
- placed in a quarantine that only exchanges heartbeats. No business
29
- data is relayed until an administrator approves the node.
30
- 3. **External connections with token use Edge Agent mode**. The token is
31
- verified for signature, TTL, audience, issuer, `node_uuid` binding,
32
- optional source IP CIDR binding, and single-use replay protection.
33
- 4. **NAT4 / no public IP uses reverse tunnel**. Authentication strength
34
- does not downgrade when the network profile is `nat4_reverse`.
35
- 5. **Trusted nodes re-auth with short-lived session tickets**. A trusted
36
- node without token or session is denied, preventing downgrade to TOFU.
23
+ 1. **Local connections default allow**, gated by `local_bypass_token` (mode `0600`), preventing unauthorized local processes from abusing `127.0.0.1`.
24
+ 2. **External connections default TOFU**. Unauthenticated nodes are quarantined to heartbeats only. No business data is relayed until approved by an administrator.
25
+ 3. **External connections with token use Edge Agent mode**. Single-use HMAC-SHA256 tokens verify signature, TTL, audience, issuer, optional node UUID, IP CIDR binding, and replay prevention.
26
+ 4. **NAT4 / no public IP uses reverse tunnel**. Authentication strength remains identical under `nat4_reverse` network profiles.
27
+ 5. **Trusted nodes re-auth with short-lived session tickets**. Trusted nodes without token or session are denied, strictly preventing downgrade attacks to TOFU.
37
28
 
38
29
  ## Architecture
39
30
 
40
31
  ```text
41
32
  +---------------------------+
42
33
  loopback ---> | AccessPolicyEngine | ---> ALLOW_LOCAL
43
- | (single decision point) |
34
+ | (Single Decision Point) |
44
35
  external ---> | | ---> ALLOW_TOKEN
45
- + token | 1. local bypass token |
46
- | 2. join token verify | ---> ALLOW_SESSION
47
- external ---> | 3. session ticket |
48
- no token | 4. TOFU pending | ---> PENDING_TOFU
49
- | 5. revoked / mismatch |
36
+ + token | 1. Local bypass token |
37
+ | 2. Join token verify | ---> ALLOW_SESSION
38
+ external ---> | 3. Session ticket |
39
+ no token | 4. TOFU quarantine | ---> PENDING_TOFU
40
+ | 5. Revoked / mismatch |
50
41
  nat4_rev ---> | | ---> DENY
51
42
  +---------------------------+
52
- |
53
- v
54
- +-------------------+
55
- | TrustStore |
56
- | pending/trusted/ |
57
- | revoked + jti |
58
- +-------------------+
59
- |
60
- v
61
- +-------------------+
62
- | AuditLogger |
63
- | JSON Lines |
64
- +-------------------+
43
+ |
44
+ v
45
+ +-------------------+
46
+ | TrustStore |
47
+ | SQLite WAL |
48
+ | pending/trusted/ |
49
+ | revoked + jti |
50
+ +-------------------+
51
+ |
52
+ v
53
+ +-------------------+
54
+ | AuditLogger |
55
+ | JSON Lines |
56
+ +-------------------+
65
57
  ```
66
58
 
67
59
  ## Decision matrix
@@ -70,98 +62,260 @@ Edge Agent token verification, session fast re-auth, and NAT4 reverse tunnel.
70
62
  |--------|----------|------------|----------------|----------------|----------|
71
63
  | direct | yes | - | - | any | `ALLOW_LOCAL` if bypass token ok, else `DENY` |
72
64
  | direct | no | valid | - | any | `ALLOW_TOKEN`, bind trust |
73
- | direct | no | invalid | - | any | `DENY` |
65
+ | direct | no | invalid/replay | - | any | `DENY` |
74
66
  | direct | no | - | valid | trusted | `ALLOW_SESSION` |
75
- | direct | no | - | - | trusted | `DENY` (no downgrade) |
76
- | direct | no | - | - | pending, same fp | `PENDING_TOFU` |
77
- | direct | no | - | - | pending, diff fp | `DENY` |
78
- | direct | no | - | - | none | `PENDING_TOFU` |
67
+ | direct | no | - | - | trusted | `DENY` (no downgrade to TOFU) |
68
+ | direct | no | - | - | pending, same fp | `PENDING_TOFU` (heartbeat only) |
69
+ | direct | no | - | - | pending, diff fp | `DENY` (fingerprint tamper prevention) |
70
+ | direct | no | - | - | none | `PENDING_TOFU` (quarantine) |
79
71
  | direct | no | - | - | revoked | `DENY` |
80
72
  | nat4_reverse | no | valid | - | any | `ALLOW_TOKEN` |
81
73
  | nat4_reverse | no | - | - | none | `PENDING_TOFU` |
82
74
 
83
- ## Install
75
+ ## Installation & Quickstart
76
+
77
+ ### 1. PyPI 安装与导入规则
78
+
79
+ > 📌 **关键名称区别**:
80
+ > - **PyPI 仓库包名(用于 pip 安装)**:`wimotek-edge-agent`(中划线 `-`)
81
+ > - **Python 导入包名(用于代码 import)**:`wimotek_edge_agent`(下划线 `_`)
82
+
83
+ 在终端中使用当前 Python 运行环境安装:
84
+
85
+ ```bash
86
+ # 推荐做法:使用当前 Python 解释器执行,避免装错环境
87
+ python -m pip install --upgrade wimotek-edge-agent
88
+ ```
89
+
90
+ 在 Python 代码中使用标准导入:
91
+
92
+ ```python
93
+ from wimotek_edge_agent import EdgeAgent, Config, AccessPolicyEngine
94
+ ```
95
+
96
+ ---
97
+
98
+ ### PyPI 安装与 ModuleNotFoundError 深度排查
99
+
100
+ 如果在上传 PyPI 后本地执行 `from wimotek_edge_agent ...` 报 `ModuleNotFoundError: No module named 'wimotek_edge_agent'`,请按以下 4 种最常见情况逐一排查:
101
+
102
+ #### 🔴 原因 1:Python 运行环境不一致(最常见,占 80%)
103
+ - **根因分析**:
104
+ 在终端中执行的 `pip install` 将包安装到了全局 Python、系统 Python 或 Conda 的 base 环境;而你在 IDE(VS Code、PyCharm、Cursor)或者运行代码时,使用的是项目独立的虚拟环境(例如 `.venv`)。
105
+ - **一键自查命令**:
106
+ 在运行报错的代码同级终端中执行:
107
+ ```bash
108
+ python -c "import sys; print('当前 Python 解释器路径:', sys.executable); print('模块搜索路径:', sys.path)"
109
+ ```
110
+ - **解决方法**:
111
+ 确保使用**执行代码的同一个 Python 解释器**进行安装:
112
+ ```bash
113
+ python -m pip install --upgrade wimotek-edge-agent
114
+ ```
115
+ 如果使用了虚拟环境,先激活虚拟环境再安装:
116
+ ```bash
117
+ # Linux / macOS:
118
+ source .venv/bin/activate
119
+ python -m pip install --upgrade wimotek-edge-agent
120
+
121
+ # Windows (PowerShell / CMD):
122
+ .venv\Scripts\activate
123
+ python -m pip install --upgrade wimotek-edge-agent
124
+ ```
125
+
126
+ ---
127
+
128
+ #### 🔴 原因 2:PyPI 全球 CDN 刷新延迟 或 国内镜像源未同步
129
+ - **根因分析**:
130
+ 1. 上传到 PyPI(通过 `twine upload dist/*`)后,PyPI 主站的全球 Fastly CDN 边缘节点通常需要 1~5 分钟同步刷新。
131
+ 2. 如果你的本地 pip 配置了国内镜像源(如清华源、阿里源、腾讯源),镜像源通常会有几十分钟到数小时的同步周期。如果刚上传就立刻通过镜像源安装,镜像源尚未同步,会提示找不到包或匹配不到版本。
132
+ 3. `pip` 本地存在 HTTP 缓存,可能命中了上传前的 404 记录。
133
+ - **解决方法**:
134
+ 直连 PyPI 官方源,并跳过本地缓存强制安装:
135
+ ```bash
136
+ python -m pip install -i https://pypi.org/simple/ --no-cache-dir --upgrade wimotek-edge-agent
137
+ ```
138
+
139
+ ---
140
+
141
+ #### 🔴 原因 3:打包时生成了“空包”(Empty Wheel 陷阱)
142
+ - **根因分析**:
143
+ 打包工具在执行 `python -m build` 或 `python setup.py bdist_wheel` 时,如果打包配置中未显式声明包路径,生成的 `.whl` 文件可能**只包含元数据 `wimotek_edge_agent-1.0.0.dist-info/`,而漏掉了 `wimotek_edge_agent/` 源码目录**。
144
+ 安装时 pip 会显示安装成功,但 `site-packages` 里其实没有代码目录,因此运行时必然报错 `ModuleNotFoundError`。
145
+ - **一键自查本地构建产物**:
146
+ 在执行打包的机器上检查生成的 `.whl` 和 `.tar.gz` 内容:
147
+ ```bash
148
+ # 检查 wheel 文件内容列表
149
+ unzip -l dist/wimotek_edge_agent-*.whl
150
+
151
+ # 或检查源码 tar 包
152
+ tar -ztvf dist/wimotek_edge_agent-*.tar.gz
153
+ ```
154
+ **正常的文件列表**必须包含以下源码文件:
155
+ ```text
156
+ wimotek_edge_agent/__init__.py
157
+ wimotek_edge_agent/agent.py
158
+ wimotek_edge_agent/config.py
159
+ wimotek_edge_agent/policy.py
160
+ wimotek_edge_agent/trust_store.py
161
+ ...
162
+ wimotek_edge_agent/py.typed
163
+ ```
164
+ 如果列表里全都是 `dist-info/` 开头的文件,而**没有** `wimotek_edge_agent/`,则说明上传的是空包!
165
+ - **彻底根治与重新发布**:
166
+ 本项目已在 `pyproject.toml` 中配置了明确的 setuptools 规则:
167
+ ```toml
168
+ [tool.setuptools]
169
+ packages = ["wimotek_edge_agent"]
170
+
171
+ [tool.setuptools.package-data]
172
+ wimotek_edge_agent = ["py.typed"]
173
+ ```
174
+ 并配备了 `MANIFEST.in` 与 `setup.py` 双重保障。重新打包与发布:
175
+ ```bash
176
+ # 1. 清理所有历史缓存构建目录
177
+ rm -rf dist build *.egg-info
178
+
179
+ # 2. 安装/升级标准构建工具
180
+ python -m pip install --upgrade build twine
181
+
182
+ # 3. 规范打包
183
+ python -m build
184
+
185
+ # 4. 验证 wheel 是否包含代码
186
+ unzip -l dist/*.whl | grep "wimotek_edge_agent/__init__.py"
187
+
188
+ # 5. 上传到 PyPI(注意修改 pyproject.toml 递增版本号,如 1.0.1)
189
+ twine upload dist/*
190
+ ```
191
+
192
+ ---
193
+
194
+ #### 🔴 原因 4:当前工作目录文件遮蔽(Shadowing)
195
+ - **根因分析**:
196
+ Python 会把执行命令的当前目录(`sys.path[0]`)作为最高优先级的模块搜索路径。如果当前目录下碰巧有一个名为 `wimotek_edge_agent.py` 的测试脚本,或者一个空的同名文件夹,Python 会直接加载该文件,导致无法找到内部类和函数。
197
+ - **排查验证**:
198
+ ```bash
199
+ python -c "import wimotek_edge_agent; print('实际加载的文件路径:', wimotek_edge_agent.__file__)"
200
+ ```
201
+ 如果路径指向了当前临时目录而非 `site-packages`,说明发生了路径遮蔽。
202
+
203
+ ---
204
+
205
+ ### 2. 本地源码免上传开发(推荐)
206
+
207
+ 在日常开发、测试或修改功能时,建议在本地项目根目录下使用可编辑模式(Editable Mode)直接安装,无需频繁上传 PyPI:
208
+
209
+ ```bash
210
+ cd wimotek-edge-agent
211
+ pip install -e .
212
+ ```
213
+
214
+ 安装后,本地对 `wimotek_edge_agent/` 目录中代码的任何改动会立即反映在 Python 环境中。验证:
84
215
 
85
216
  ```bash
86
- pip install edge-agent-commercial
217
+ python -c "import wimotek_edge_agent; print('版本:', wimotek_edge_agent.__version__, '路径:', wimotek_edge_agent.__file__)"
87
218
  ```
88
219
 
89
- From source:
220
+ ---
221
+
222
+ ### 3. Node.js CLI Runner
223
+
224
+ 除了 Python CLI 外,还可通过 npm / npx 直接运行:
90
225
 
91
226
  ```bash
92
- git clone https://github.com/yourorg/edge-agent.git
93
- cd edge-agent
94
- pip install -e ".[dev]"
227
+ npm install -g wimotek-edge-agent
228
+ # 或者直接免安装执行:
229
+ npx wimotek-edge-agent --help
95
230
  ```
96
231
 
97
232
  ## Environment variables
98
233
 
99
234
  | Variable | Required | Default | Description |
100
235
  |----------|----------|---------|-------------|
101
- | `EDGE_AGENT_DATA_DIR` | no | `./edge_agent_data` | Data directory for trust DB, audit log, local bypass token |
236
+ | `EDGE_AGENT_DATA_DIR` | no | `./edge_agent_data` | Persistent data directory for trust DB, audit log, local bypass token |
102
237
  | `EDGE_AGENT_TOKEN_SECRET` | yes | - | HMAC secret, >= 32 bytes |
103
- | `EDGE_AGENT_TOKEN_AUDIENCE` | no | `edge-agent` | Token audience |
104
- | `EDGE_AGENT_TOKEN_ISSUER` | no | `central` | Token issuer |
105
- | `EDGE_AGENT_TOKEN_MAX_TTL_SECONDS` | no | `600` | Max token TTL |
106
- | `EDGE_AGENT_SESSION_TICKET_TTL_SECONDS` | no | `300` | Session ticket TTL |
238
+ | `EDGE_AGENT_TOKEN_AUDIENCE` | no | `edge-agent` | Token audience claim |
239
+ | `EDGE_AGENT_TOKEN_ISSUER` | no | `central` | Token issuer claim |
240
+ | `EDGE_AGENT_TOKEN_MAX_TTL_SECONDS` | no | `600` | Max token TTL (seconds) |
241
+ | `EDGE_AGENT_SESSION_TICKET_TTL_SECONDS` | no | `300` | Session ticket TTL (seconds) |
107
242
  | `EDGE_AGENT_TOFU_HEARTBEAT_SECONDS` | no | `10` | TOFU quarantine heartbeat interval |
108
243
  | `EDGE_AGENT_REQUIRE_LOCAL_BYPASS_TOKEN` | no | `true` | Require local bypass token for loopback |
109
- | `EDGE_AGENT_REVERSE_TUNNEL` | no | auto | `true` / `false` / unset |
110
- | `EDGE_AGENT_NAT_TYPE` | no | `unknown` | `nat4`, `symmetric`, etc. |
244
+ | `EDGE_AGENT_REVERSE_TUNNEL` | no | auto | Force reverse tunnel (`true`/`false`) |
245
+ | `EDGE_AGENT_NAT_TYPE` | no | `unknown` | Network NAT type (`nat4`, `symmetric`, etc.) |
111
246
  | `EDGE_AGENT_NODE_UUID` | reverse only | - | Agent node UUID |
112
247
  | `EDGE_AGENT_PUBLIC_KEY_FINGERPRINT` | reverse only | - | Agent public key fingerprint |
113
- | `EDGE_AGENT_JOIN_TOKEN` | reverse only | - | Join token for Edge Agent mode |
114
- | `EDGE_AGENT_TLS_SERVER_NAME` | no | - | TLS SNI |
115
- | `EDGE_AGENT_CERTIFICATE_PIN_SHA256` | no | - | TLS certificate pin |
248
+ | `EDGE_AGENT_JOIN_TOKEN` | reverse only | - | Join token for reverse tunnel connection |
249
+ | `EDGE_AGENT_TLS_SERVER_NAME` | no | - | TLS SNI hostname |
250
+ | `EDGE_AGENT_CERTIFICATE_PIN_SHA256` | no | - | TLS certificate SHA-256 pin |
116
251
 
117
- Generate a secret:
252
+ Generate a high-entropy secret:
118
253
 
119
254
  ```bash
120
- python -c "import secrets; print(secrets.token_urlsafe(48))"
255
+ wimotek-edge-agent generate-secret
121
256
  ```
122
257
 
123
258
  ## CLI usage
124
259
 
125
- Start a direct TLS JSON-line server:
260
+ Start a direct TLS / TCP JSON-line server:
126
261
 
127
262
  ```bash
128
- edge-agent listen --host 0.0.0.0 --port 8443 \
263
+ wimotek-edge-agent listen --host 0.0.0.0 --port 8443 \
129
264
  --cert /path/server.crt --key /path/server.key
130
265
  ```
131
266
 
132
- Approve a pending TOFU node (admin, out-of-band):
267
+ Approve a pending TOFU node (admin out-of-band verification):
268
+
269
+ ```bash
270
+ wimotek-edge-agent approve --node-uuid node-xxx --fingerprint sha256:...
271
+ ```
272
+
273
+ Revoke a compromised or decommissioned node:
133
274
 
134
275
  ```bash
135
- edge-agent approve --node-uuid node-xxx --fingerprint sha256:...
276
+ wimotek-edge-agent revoke --node-uuid node-xxx
136
277
  ```
137
278
 
138
- Revoke a trusted node:
279
+ List all managed nodes in trust store:
139
280
 
140
281
  ```bash
141
- edge-agent revoke --node-uuid node-xxx
282
+ wimotek-edge-agent list-nodes --status trusted
142
283
  ```
143
284
 
144
- Show a node record:
285
+ Inspect a specific node record:
145
286
 
146
287
  ```bash
147
- edge-agent show-node --node-uuid node-xxx
288
+ wimotek-edge-agent show-node --node-uuid node-xxx
148
289
  ```
149
290
 
150
291
  Issue a single-use Edge Agent join token:
151
292
 
152
293
  ```bash
153
- edge-agent issue-token \
154
- --node-group group-a \
294
+ wimotek-edge-agent issue-token \
295
+ --node-group fleet-a \
155
296
  --ttl 300 \
156
297
  --node-uuid node-xxx \
157
298
  --allowed-ip-cidr 203.0.113.0/24
158
299
  ```
159
300
 
301
+ Verify a token directly:
302
+
303
+ ```bash
304
+ wimotek-edge-agent verify-token --token <token_string> --source-ip 203.0.113.10
305
+ ```
306
+
307
+ Read or generate the local bypass token:
308
+
309
+ ```bash
310
+ wimotek-edge-agent bypass-token
311
+ ```
312
+
160
313
  ## Programmatic usage
161
314
 
162
315
  ```python
163
316
  import asyncio
164
- from edge_agent import Config, EdgeAgent
317
+ from wimotek_edge_agent import Config, EdgeAgent
318
+ from wimotek_edge_agent.transport import JsonLineTransport
165
319
 
166
320
  async def main() -> None:
167
321
  config = Config.from_env()
@@ -177,7 +331,7 @@ async def main() -> None:
177
331
 
178
332
  server = await asyncio.start_server(
179
333
  lambda r, w: agent.handle_connection(
180
- __import__("edge_agent.transport", fromlist=["JsonLineTransport"]).JsonLineTransport(r, w),
334
+ JsonLineTransport(r, w),
181
335
  w.get_extra_info("peername")[0],
182
336
  ),
183
337
  host="0.0.0.0",
@@ -186,13 +340,13 @@ async def main() -> None:
186
340
  async with server:
187
341
  await server.serve_forever()
188
342
 
189
- asyncio.run(main())
343
+ if __name__ == "__main__":
344
+ asyncio.run(main())
190
345
  ```
191
346
 
192
347
  ## Reverse tunnel / NAT4
193
348
 
194
- `transport.TunnelDialer` is a protocol. Implement it with your existing
195
- WebSocket stack, then call:
349
+ Implement `TunnelDialer` with your WebSocket stack, then launch:
196
350
 
197
351
  ```python
198
352
  await agent.run_reverse_tunnel(
@@ -203,19 +357,10 @@ await agent.run_reverse_tunnel(
203
357
  )
204
358
  ```
205
359
 
206
- The reverse tunnel:
207
-
208
- - uses TLS and supports certificate pinning via
209
- `EDGE_AGENT_CERTIFICATE_PIN_SHA256`;
210
- - sends the same handshake fields as direct mode (`node_uuid`,
211
- `public_key_fingerprint`, `join_token`, `nat_type`, `reverse_tunnel`);
212
- - keeps waiting on `pending_tofu` until the central side sends
213
- `approved` or `denied`;
214
- - applies exponential backoff on reconnect.
215
-
216
- Authentication is performed by the central side using the same
217
- `AccessPolicyEngine`. The network profile is `nat4_reverse`; the auth
218
- strength is identical to direct mode.
360
+ - Sends handshake fields (`node_uuid`, `public_key_fingerprint`, `join_token`, `nat_type`, `reverse_tunnel`);
361
+ - Supports TLS pinning via `EDGE_AGENT_CERTIFICATE_PIN_SHA256`;
362
+ - Quarantines in `pending_tofu` until approved by central administrator;
363
+ - Exponential backoff on reconnection attempts.
219
364
 
220
365
  ## Audit log
221
366
 
@@ -225,60 +370,29 @@ Every `AccessDecision` is written to `EDGE_AGENT_DATA_DIR/audit.jsonl`:
225
370
  {"ts":"2026-11-06T12:00:00+00:00","event":"access_decision","node_uuid":"node-xxx","source_ip":"203.0.113.10","is_loopback":false,"decision":"allow_token","reason":"edge_agent_token_verified","network_profile":"direct","nat_type":"unknown","fingerprint":"sha256:...","node_status":"trusted"}
226
371
  ```
227
372
 
228
- Ship this file to your central log platform (Loki, ELK, CloudWatch, etc.).
229
- Do not store the raw join token in the audit log; only record the decision.
373
+ Ship this structured log file to your central logging pipeline (Loki, ELK, CloudWatch, OpenTelemetry). Raw join tokens are never logged.
230
374
 
231
375
  ## Security model
232
376
 
233
- - **Token secret**: >= 32 bytes. Rotate by restarting with a new secret;
234
- old tokens become invalid immediately.
235
- - **Token single use**: `jti` is stored in `used_tokens` until `exp`.
236
- - **Token binding**: optional `node_uuid` and `allowed_ip_cidr`.
237
- - **TOFU quarantine**: pending nodes exchange heartbeat only. No data
238
- relay, no business messages.
239
- - **Fingerprint mismatch**: pending node presenting a different
240
- fingerprint is denied; trusted node presenting a different fingerprint
241
- is denied.
242
- - **No downgrade**: trusted node without token or session is denied.
243
- - **Local bypass**: `local_bypass.token` is written with mode `0600`.
244
- Set `EDGE_AGENT_REQUIRE_LOCAL_BYPASS_TOKEN=false` only if you fully
245
- trust every process on the host.
246
- - **TLS pinning**: optional SHA-256 pin of the peer certificate.
247
- - **Audit**: every decision is logged. Revocation is immediate on the
248
- next handshake.
377
+ - **Token Secret**: >= 32 bytes HMAC secret.
378
+ - **Single-Use Replay Protection**: `jti` is stored in SQLite `used_tokens` until expiration. Subsequent attempts are rejected with `token replay detected`.
379
+ - **Identity & Network Binding**: Tokens can be bound to specific `node_uuid` and `allowed_ip_cidr` networks.
380
+ - **TOFU Quarantine**: Unapproved nodes only exchange heartbeats; business data relay is strictly forbidden.
381
+ - **No Downgrade**: Trusted nodes presenting without credentials cannot downgrade to TOFU.
382
+ - **Local Bypass Hardening**: `local_bypass.token` is generated with POSIX `0600` permissions.
383
+ - **Immediate Revocation**: Revoked nodes are denied on the next handshake packet across all network profiles.
249
384
 
250
385
  ## Commercial deployment checklist
251
386
 
252
- - [ ] Rotate `EDGE_AGENT_TOKEN_SECRET` per environment.
253
- - [ ] Store `EDGE_AGENT_DATA_DIR` on persistent, encrypted storage.
254
- - [ ] Ship `audit.jsonl` to central logging with retention policy.
255
- - [ ] Enable TLS with a valid certificate; set
256
- `EDGE_AGENT_CERTIFICATE_PIN_SHA256` for reverse tunnel.
257
- - [ ] Restrict `edge-agent approve` to an admin-only host or CI job.
258
- - [ ] Monitor `tofu_pending_queue_size`, `token_replay_rejections`,
259
- `reverse_tunnel_reconnect_count`.
260
- - [ ] Run `edge-agent revoke` on node decommission.
261
- - [ ] Back up `trust_store.sqlite3` before upgrades.
262
- - [ ] Pin Python and Node versions in CI.
263
- - [ ] Publish PyPI and npm from tagged releases only.
264
-
265
- ## Publishing
266
-
267
- PyPI:
268
-
269
- ```bash
270
- python -m build
271
- python -m twine check dist/*
272
- python -m twine upload dist/*
273
- ```
274
-
275
- npm (CLI wrapper):
276
-
277
- ```bash
278
- cd npm
279
- npm pack --dry-run
280
- npm publish --access public
281
- ```
387
+ - [ ] Set unique `EDGE_AGENT_TOKEN_SECRET` (>= 32 bytes) per environment.
388
+ - [ ] Mount `EDGE_AGENT_DATA_DIR` on persistent, encrypted storage.
389
+ - [ ] Verify `local_bypass.token` POSIX permissions (`0600`).
390
+ - [ ] Ship `audit.jsonl` to central security SIEM.
391
+ - [ ] Enable TLS; configure `EDGE_AGENT_CERTIFICATE_PIN_SHA256` for reverse tunnels.
392
+ - [ ] Restrict `wimotek-edge-agent approve` to admin-only bastion hosts or CI pipelines.
393
+ - [ ] Monitor `tofu_pending_queue_size` and `token_replay_rejections`.
394
+ - [ ] Execute `wimotek-edge-agent revoke` upon node decommissioning.
395
+ - [ ] Back up `trust_store.sqlite3` prior to upgrades.
282
396
 
283
397
  ## License
284
398
 
package/bin/cli.js CHANGED
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  const { spawn } = require('child_process');
3
3
 
4
- const args = ['-m', 'wimotek_edge_agent.agent', ...process.argv.slice(2)];
5
- const py = spawn('python3', args, { stdio: 'inherit', env: process.env });
4
+ const path = require('path');
5
+ const packageDir = path.resolve(__dirname, '..');
6
+ const env = { ...process.env };
7
+ env.PYTHONPATH = env.PYTHONPATH ? `${packageDir}:${env.PYTHONPATH}` : packageDir;
8
+
9
+ const args = ['-m', 'wimotek_edge_agent', ...process.argv.slice(2)];
10
+ const py = spawn('python3', args, { stdio: 'inherit', env });
6
11
 
7
12
  py.on('error', () => {
8
- console.error('❌ 请确保宿主机已安装 Python 3 和 wimotek-edge-agent: pip install wimotek-edge-agent');
13
+ console.error('❌ 请确保宿主机已安装 Python 3 环境与 wimotek-edge-agent 依赖。');
9
14
  process.exit(1);
10
15
  });
11
16
 
12
17
  py.on('close', (code) => {
13
- process.exit(code);
18
+ process.exit(code ?? 0);
14
19
  });
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "wimotek-edge-agent",
3
- "version": "1.0.5",
4
- "description": "Universal Edge Tunnel Agent CLI Runner",
5
- "main": "index.js",
3
+ "version": "1.0.7",
4
+ "description": "Universal Edge Reverse Proxy & Service Bridge Agent CLI Runner",
6
5
  "bin": {
6
+ "wimotek-edge-agent": "./bin/cli.js",
7
+ "wimotek-agent": "./bin/cli.js",
7
8
  "edge-agent": "./bin/cli.js"
8
9
  },
9
10
  "keywords": [
11
+ "wimotek",
10
12
  "edge",
11
13
  "agent",
12
14
  "tunnel",
13
15
  "reverse-proxy",
14
- "cdp",
15
- "vnc"
16
+ "zero-trust",
17
+ "tofu",
18
+ "hmac-token"
16
19
  ],
17
- "author": "wimotek",
18
- "license": "MIT"
20
+ "author": "Wimotek <wimotek@gmail.com>",
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=16.0.0"
24
+ }
19
25
  }
package/pyproject.toml CHANGED
@@ -6,15 +6,48 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "wimotek-edge-agent"
9
- version = "1.0.5"
9
+ version = "1.0.0.7"
10
10
  description = "Universal Edge Reverse Proxy & Service Bridge Agent"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
13
+ authors = [
14
+ { name = "Wimotek", email = "wimotek@gmail.com" },
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ "Topic :: Internet :: Proxy Servers",
25
+ "Topic :: Security :: Cryptography",
26
+ ]
13
27
  dependencies = [
14
28
  "httpx>=0.24.0",
15
29
  "websockets>=11.0",
16
30
  ]
17
31
 
32
+ [project.license]
33
+ text = "MIT"
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=7.0",
38
+ ]
39
+
18
40
  [project.scripts]
19
- edge-agent = "wimotek_edge_agent.agent:main"
41
+ wimotek-edge-agent = "wimotek_edge_agent.agent:main"
20
42
  wimotek-agent = "wimotek_edge_agent.agent:main"
43
+ edge-agent = "wimotek_edge_agent.agent:main"
44
+
45
+ [tool.setuptools]
46
+ packages = [
47
+ "wimotek_edge_agent",
48
+ ]
49
+
50
+ [tool.setuptools.package-data]
51
+ wimotek_edge_agent = [
52
+ "py.typed",
53
+ ]
package/setup.py ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ """Standard setup.py shim for backward compatibility with older build tools and editable installs."""
3
+ from setuptools import setup, find_packages
4
+
5
+ setup(
6
+ packages=find_packages(include=["wimotek_edge_agent", "wimotek_edge_agent.*"]),
7
+ package_data={
8
+ "wimotek_edge_agent": ["py.typed"],
9
+ },
10
+ )
@@ -0,0 +1,5 @@
1
+ from .agent import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -4,6 +4,7 @@ import asyncio
4
4
  import ipaddress
5
5
  import ssl
6
6
  import time
7
+ from pathlib import Path
7
8
  from typing import Awaitable, Callable, Optional
8
9
 
9
10
  from .audit import AuditLogger
@@ -358,4 +359,275 @@ class EdgeAgent:
358
359
  if transport is not None:
359
360
  await transport.close()
360
361
  await asyncio.sleep(backoff)
361
- backoff = min(backoff * 2, 60)
362
+ backoff = min(backoff * 2, 60)
363
+
364
+
365
+ def build_parser() -> argparse.ArgumentParser:
366
+ parser = argparse.ArgumentParser(
367
+ prog="wimotek-edge-agent",
368
+ description="Universal Edge Tunnel & Access Policy Agent",
369
+ )
370
+ parser.add_argument(
371
+ "--data-dir",
372
+ type=str,
373
+ default=None,
374
+ help="Custom path for edge agent data directory",
375
+ )
376
+ parser.add_argument(
377
+ "--secret",
378
+ type=str,
379
+ default=None,
380
+ help="HMAC secret key (min 32 bytes) or env EDGE_AGENT_TOKEN_SECRET",
381
+ )
382
+
383
+ subparsers = parser.add_subparsers(dest="command", required=True)
384
+
385
+ # 1. listen
386
+ p_listen = subparsers.add_parser("listen", help="Start direct TLS/TCP listener")
387
+ p_listen.add_argument("--host", default="0.0.0.0", help="Listen host (default 0.0.0.0)")
388
+ p_listen.add_argument("--port", type=int, default=8443, help="Listen port (default 8443)")
389
+ p_listen.add_argument("--cert", default=None, help="Path to TLS certificate file")
390
+ p_listen.add_argument("--key", default=None, help="Path to TLS private key file")
391
+
392
+ # 2. approve
393
+ p_app = subparsers.add_parser("approve", help="Approve a pending TOFU node")
394
+ p_app.add_argument("--node-uuid", required=True, help="Target node UUID")
395
+ p_app.add_argument("--fingerprint", default=None, help="Expected public key fingerprint")
396
+
397
+ # 3. revoke
398
+ p_rev = subparsers.add_parser("revoke", help="Revoke a trusted node")
399
+ p_rev.add_argument("--node-uuid", required=True, help="Target node UUID")
400
+
401
+ # 4. show-node
402
+ p_show = subparsers.add_parser("show-node", help="Inspect a node record")
403
+ p_show.add_argument("--node-uuid", required=True, help="Target node UUID")
404
+
405
+ # 5. list-nodes
406
+ p_list = subparsers.add_parser("list-nodes", help="List nodes in Trust Store")
407
+ p_list.add_argument(
408
+ "--status",
409
+ choices=["pending", "trusted", "revoked", "all"],
410
+ default="all",
411
+ help="Filter by trust status",
412
+ )
413
+ p_list.add_argument("--json", action="store_true", help="Output JSON lines")
414
+
415
+ # 6. delete-node
416
+ p_del = subparsers.add_parser("delete-node", help="Delete a node record")
417
+ p_del.add_argument("--node-uuid", required=True, help="Target node UUID")
418
+
419
+ # 7. issue-token
420
+ p_iss = subparsers.add_parser("issue-token", help="Issue single-use HMAC join token")
421
+ p_iss.add_argument("--node-group", default="default", help="Node group tag")
422
+ p_iss.add_argument("--ttl", type=int, default=300, help="TTL in seconds (default 300)")
423
+ p_iss.add_argument("--node-uuid", default=None, help="Bind token to specific node UUID")
424
+ p_iss.add_argument("--allowed-ip-cidr", default=None, help="Bind token to source IP/CIDR")
425
+
426
+ # 8. verify-token
427
+ p_ver = subparsers.add_parser("verify-token", help="Verify a join token")
428
+ p_ver.add_argument("--token", required=True, help="Join token string")
429
+ p_ver.add_argument("--source-ip", default="127.0.0.1", help="Source IP to test against")
430
+ p_ver.add_argument("--node-uuid", default="", help="Node UUID to test against")
431
+
432
+ # 9. generate-secret
433
+ subparsers.add_parser("generate-secret", help="Generate a high-entropy 48-byte secret")
434
+
435
+ # 10. bypass-token
436
+ subparsers.add_parser("bypass-token", help="Ensure and show local bypass token")
437
+
438
+ # 11. stats
439
+ subparsers.add_parser("stats", help="Show summary statistics of Trust Store")
440
+
441
+ # 12. simulate
442
+ p_sim = subparsers.add_parser("simulate", help="Simulate Access Policy handshake")
443
+ p_sim.add_argument("--source-ip", default="127.0.0.1")
444
+ p_sim.add_argument("--node-uuid", default="sim-node-1")
445
+ p_sim.add_argument("--fingerprint", default="sha256:simulated")
446
+ p_sim.add_argument("--join-token", default=None)
447
+ p_sim.add_argument("--local-bypass-token", default=None)
448
+ p_sim.add_argument("--session-ticket", default=None)
449
+ p_sim.add_argument("--reverse-tunnel", action="store_true")
450
+ p_sim.add_argument("--nat-type", default="unknown")
451
+
452
+ return parser
453
+
454
+
455
+ def _init_agent_for_cli(args: argparse.Namespace) -> EdgeAgent:
456
+ config = Config.from_env()
457
+ if args.data_dir:
458
+ config.data_dir = Path(args.data_dir)
459
+ if args.secret:
460
+ config.token_secret = args.secret.encode("utf-8")
461
+ elif not config.token_secret:
462
+ # Default fallback secret for CLI inspection if not configured
463
+ config.token_secret = b"edge-agent-commercial-default-hmac-secret-32b"
464
+ return EdgeAgent(config)
465
+
466
+
467
+ def main(argv: Optional[list[str]] = None) -> int:
468
+ import json
469
+ import sys
470
+ from dataclasses import asdict
471
+
472
+ parser = build_parser()
473
+ args = parser.parse_args(argv)
474
+
475
+ if args.command == "generate-secret":
476
+ import secrets
477
+ secret = secrets.token_urlsafe(48)
478
+ print(secret)
479
+ return 0
480
+
481
+ agent = _init_agent_for_cli(args)
482
+
483
+ if args.command == "bypass-token":
484
+ token = agent.local_bypass.ensure_token()
485
+ print(json.dumps({
486
+ "token": token,
487
+ "path": str(agent.config.local_bypass_token_path),
488
+ "required": agent.config.require_local_bypass_token,
489
+ }, indent=2))
490
+ return 0
491
+
492
+ if args.command == "stats":
493
+ stats = agent.trust_store.get_stats()
494
+ print(json.dumps(stats, indent=2))
495
+ return 0
496
+
497
+ if args.command == "show-node":
498
+ node = agent.trust_store.get_node(args.node_uuid)
499
+ if node is None:
500
+ sys.stderr.write(f"Node '{args.node_uuid}' not found in trust store.\n")
501
+ return 1
502
+ print(json.dumps(asdict(node), indent=2, ensure_ascii=False))
503
+ return 0
504
+
505
+ if args.command == "list-nodes":
506
+ status_filter = None
507
+ if args.status and args.status != "all":
508
+ status_filter = TrustStatus(args.status)
509
+ nodes = agent.trust_store.list_nodes(status_filter)
510
+ if args.json:
511
+ for n in nodes:
512
+ print(json.dumps(asdict(n), ensure_ascii=False))
513
+ else:
514
+ print(json.dumps([asdict(n) for n in nodes], indent=2, ensure_ascii=False))
515
+ return 0
516
+
517
+ if args.command == "approve":
518
+ ok = agent.trust_store.approve_tofu(args.node_uuid, args.fingerprint)
519
+ if ok:
520
+ agent.audit.emit("node_approved", node_uuid=args.node_uuid, fingerprint=args.fingerprint)
521
+ print(json.dumps({"status": "ok", "message": f"Node '{args.node_uuid}' approved as TRUSTED"}, indent=2))
522
+ return 0
523
+ else:
524
+ sys.stderr.write(f"Failed to approve node '{args.node_uuid}' (not found or fingerprint mismatch).\n")
525
+ return 1
526
+
527
+ if args.command == "revoke":
528
+ ok = agent.trust_store.revoke(args.node_uuid)
529
+ if ok:
530
+ agent.audit.emit("node_revoked", node_uuid=args.node_uuid)
531
+ print(json.dumps({"status": "ok", "message": f"Node '{args.node_uuid}' marked as REVOKED"}, indent=2))
532
+ return 0
533
+ else:
534
+ sys.stderr.write(f"Node '{args.node_uuid}' not found.\n")
535
+ return 1
536
+
537
+ if args.command == "delete-node":
538
+ ok = agent.trust_store.delete_node(args.node_uuid)
539
+ if ok:
540
+ agent.audit.emit("node_deleted", node_uuid=args.node_uuid)
541
+ print(json.dumps({"status": "ok", "message": f"Node '{args.node_uuid}' deleted"}, indent=2))
542
+ return 0
543
+ else:
544
+ sys.stderr.write(f"Node '{args.node_uuid}' not found.\n")
545
+ return 1
546
+
547
+ if args.command == "issue-token":
548
+ try:
549
+ token = agent.token_issuer.issue(
550
+ node_group=args.node_group,
551
+ ttl_seconds=args.ttl,
552
+ node_uuid=args.node_uuid,
553
+ allowed_ip_cidr=args.allowed_ip_cidr,
554
+ )
555
+ print(token)
556
+ return 0
557
+ except Exception as e:
558
+ sys.stderr.write(f"Failed to issue token: {e}\n")
559
+ return 1
560
+
561
+ if args.command == "verify-token":
562
+ try:
563
+ claims = agent.token_verifier.verify(
564
+ token=args.token,
565
+ source_ip=args.source_ip,
566
+ node_uuid=args.node_uuid,
567
+ )
568
+ print(json.dumps(asdict(claims), indent=2))
569
+ return 0
570
+ except Exception as e:
571
+ sys.stderr.write(f"Token verification failed: {e}\n")
572
+ return 1
573
+
574
+ if args.command == "simulate":
575
+ is_loop = agent._is_loopback(args.source_ip)
576
+ ctx = ConnectionContext(
577
+ source_ip=args.source_ip,
578
+ is_loopback=is_loop,
579
+ node_uuid=args.node_uuid,
580
+ public_key_fingerprint=args.fingerprint,
581
+ join_token=args.join_token,
582
+ local_bypass_token=args.local_bypass_token,
583
+ session_ticket=args.session_ticket,
584
+ nat_type=args.nat_type,
585
+ reverse_tunnel=args.reverse_tunnel,
586
+ )
587
+ res = agent.authenticate(ctx)
588
+ out = {
589
+ "decision": res.decision.value,
590
+ "reason": res.reason,
591
+ "network_profile": res.network_profile,
592
+ "node_status": res.node_status.value if res.node_status else None,
593
+ }
594
+ print(json.dumps(out, indent=2))
595
+ return 0
596
+
597
+ if args.command == "listen":
598
+ ssl_ctx = None
599
+ if args.cert and args.key:
600
+ import ssl
601
+ ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
602
+ ssl_ctx.load_cert_chain(certfile=args.cert, keyfile=args.key)
603
+
604
+ async def _run_server():
605
+ server = await asyncio.start_server(
606
+ lambda r, w: agent.handle_connection(
607
+ JsonLineTransport(r, w),
608
+ w.get_extra_info("peername")[0] if w.get_extra_info("peername") else "unknown",
609
+ ),
610
+ host=args.host,
611
+ port=args.port,
612
+ ssl=ssl_ctx,
613
+ )
614
+ proto = "TLS" if ssl_ctx else "Plain TCP"
615
+ sys.stderr.write(f"[edge-agent] Listening on {args.host}:{args.port} ({proto})\n")
616
+ async with server:
617
+ await server.serve_forever()
618
+
619
+ try:
620
+ asyncio.run(_run_server())
621
+ except KeyboardInterrupt:
622
+ sys.stderr.write("[edge-agent] Server stopped by user\n")
623
+ return 0
624
+
625
+ return 0
626
+
627
+
628
+ if __name__ == "__main__":
629
+ import argparse
630
+ import sys
631
+ sys.exit(main())
632
+ else:
633
+ import argparse
File without changes
@@ -185,6 +185,53 @@ class TrustStore:
185
185
  return False
186
186
  return True
187
187
 
188
+ def list_nodes(self, status: Optional[TrustStatus] = None) -> list[NodeRecord]:
189
+ with self._connect() as conn:
190
+ if status is not None:
191
+ rows = conn.execute(
192
+ "SELECT * FROM nodes WHERE status = ? ORDER BY first_seen DESC",
193
+ (status.value,),
194
+ ).fetchall()
195
+ else:
196
+ rows = conn.execute(
197
+ "SELECT * FROM nodes ORDER BY first_seen DESC"
198
+ ).fetchall()
199
+ return [
200
+ NodeRecord(
201
+ node_uuid=r["node_uuid"],
202
+ fingerprint=r["fingerprint"],
203
+ status=TrustStatus(r["status"]),
204
+ first_seen=r["first_seen"],
205
+ approved_at=r["approved_at"],
206
+ network_profile=r["network_profile"],
207
+ metadata=json.loads(r["metadata"] or "{}"),
208
+ )
209
+ for r in rows
210
+ ]
211
+
212
+ def delete_node(self, node_uuid: str) -> bool:
213
+ with self._connect() as conn:
214
+ cur = conn.execute("DELETE FROM nodes WHERE node_uuid = ?", (node_uuid,))
215
+ return cur.rowcount > 0
216
+
217
+ def get_stats(self) -> dict[str, int]:
218
+ with self._connect() as conn:
219
+ nodes_rows = conn.execute(
220
+ "SELECT status, COUNT(*) as cnt FROM nodes GROUP BY status"
221
+ ).fetchall()
222
+ status_counts = {r["status"]: r["cnt"] for r in nodes_rows}
223
+ used_tokens_count = conn.execute(
224
+ "SELECT COUNT(*) as cnt FROM used_tokens"
225
+ ).fetchone()["cnt"]
226
+
227
+ return {
228
+ "total_nodes": sum(status_counts.values()),
229
+ "trusted_nodes": status_counts.get(TrustStatus.TRUSTED.value, 0),
230
+ "pending_nodes": status_counts.get(TrustStatus.PENDING.value, 0),
231
+ "revoked_nodes": status_counts.get(TrustStatus.REVOKED.value, 0),
232
+ "used_tokens_count": used_tokens_count,
233
+ }
234
+
188
235
  def consume_token(self, jti: str, expires_at: int) -> bool:
189
236
  """
190
237
  返回 True 表示首次使用;False 表示重放。
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes