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 +21 -0
- package/MANIFEST.in +5 -0
- package/README.md +250 -136
- package/bin/cli.js +9 -4
- package/package.json +13 -7
- package/pyproject.toml +35 -2
- package/setup.py +10 -0
- package/wimotek_edge_agent/__main__.py +5 -0
- package/{edge_agent → wimotek_edge_agent}/agent.py +273 -1
- package/wimotek_edge_agent/py.typed +0 -0
- package/{edge_agent → wimotek_edge_agent}/trust_store.py +47 -0
- /package/{edge_agent → wimotek_edge_agent}/__init__.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/audit.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/config.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/local_bypass.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/models.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/nat.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/policy.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/tokens.py +0 -0
- /package/{edge_agent → wimotek_edge_agent}/transport.py +0 -0
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
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Wimotek Edge Agent
|
|
2
2
|
|
|
3
|
-
Commercial-grade edge agent
|
|
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
|
-
- [
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
| (
|
|
34
|
+
| (Single Decision Point) |
|
|
44
35
|
external ---> | | ---> ALLOW_TOKEN
|
|
45
|
-
+ token | 1.
|
|
46
|
-
| 2.
|
|
47
|
-
external ---> | 3.
|
|
48
|
-
no token | 4. TOFU
|
|
49
|
-
| 5.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
217
|
+
python -c "import wimotek_edge_agent; print('版本:', wimotek_edge_agent.__version__, '路径:', wimotek_edge_agent.__file__)"
|
|
87
218
|
```
|
|
88
219
|
|
|
89
|
-
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### 3. Node.js CLI Runner
|
|
223
|
+
|
|
224
|
+
除了 Python CLI 外,还可通过 npm / npx 直接运行:
|
|
90
225
|
|
|
91
226
|
```bash
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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` |
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
276
|
+
wimotek-edge-agent revoke --node-uuid node-xxx
|
|
136
277
|
```
|
|
137
278
|
|
|
138
|
-
|
|
279
|
+
List all managed nodes in trust store:
|
|
139
280
|
|
|
140
281
|
```bash
|
|
141
|
-
edge-agent
|
|
282
|
+
wimotek-edge-agent list-nodes --status trusted
|
|
142
283
|
```
|
|
143
284
|
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
asyncio.run(main())
|
|
190
345
|
```
|
|
191
346
|
|
|
192
347
|
## Reverse tunnel / NAT4
|
|
193
348
|
|
|
194
|
-
`
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
-
|
|
209
|
-
|
|
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
|
|
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
|
|
234
|
-
|
|
235
|
-
- **
|
|
236
|
-
- **
|
|
237
|
-
- **
|
|
238
|
-
|
|
239
|
-
- **
|
|
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
|
-
- [ ]
|
|
253
|
-
- [ ]
|
|
254
|
-
- [ ]
|
|
255
|
-
- [ ]
|
|
256
|
-
|
|
257
|
-
- [ ] Restrict `edge-agent approve` to
|
|
258
|
-
- [ ] Monitor `tofu_pending_queue_size
|
|
259
|
-
|
|
260
|
-
- [ ]
|
|
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
|
|
5
|
-
const
|
|
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
|
|
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.
|
|
4
|
-
"description": "Universal Edge
|
|
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
|
-
"
|
|
15
|
-
"
|
|
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.
|
|
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
|
+
)
|
|
@@ -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
|
|
File without changes
|