wimotek-edge-agent 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -1,14 +1,13 @@
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)
12
11
  - [Environment variables](#environment-variables)
13
12
  - [CLI usage](#cli-usage)
14
13
  - [Programmatic usage](#programmatic-usage)
@@ -16,52 +15,44 @@ Edge Agent token verification, session fast re-auth, and NAT4 reverse tunnel.
16
15
  - [Audit log](#audit-log)
17
16
  - [Security model](#security-model)
18
17
  - [Commercial deployment checklist](#commercial-deployment-checklist)
19
- - [Publishing](#publishing)
20
18
  - [License](#license)
21
19
 
22
20
  ## Design goals
23
21
 
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.
22
+ 1. **Local connections default allow**, gated by `local_bypass_token` (mode `0600`), preventing unauthorized local processes from abusing `127.0.0.1`.
23
+ 2. **External connections default TOFU**. Unauthenticated nodes are quarantined to heartbeats only. No business data is relayed until approved by an administrator.
24
+ 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.
25
+ 4. **NAT4 / no public IP uses reverse tunnel**. Authentication strength remains identical under `nat4_reverse` network profiles.
26
+ 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
27
 
38
28
  ## Architecture
39
29
 
40
30
  ```text
41
31
  +---------------------------+
42
32
  loopback ---> | AccessPolicyEngine | ---> ALLOW_LOCAL
43
- | (single decision point) |
33
+ | (Single Decision Point) |
44
34
  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 |
35
+ + token | 1. Local bypass token |
36
+ | 2. Join token verify | ---> ALLOW_SESSION
37
+ external ---> | 3. Session ticket |
38
+ no token | 4. TOFU quarantine | ---> PENDING_TOFU
39
+ | 5. Revoked / mismatch |
50
40
  nat4_rev ---> | | ---> DENY
51
41
  +---------------------------+
52
- |
53
- v
54
- +-------------------+
55
- | TrustStore |
56
- | pending/trusted/ |
57
- | revoked + jti |
58
- +-------------------+
59
- |
60
- v
61
- +-------------------+
62
- | AuditLogger |
63
- | JSON Lines |
64
- +-------------------+
42
+ |
43
+ v
44
+ +-------------------+
45
+ | TrustStore |
46
+ | SQLite WAL |
47
+ | pending/trusted/ |
48
+ | revoked + jti |
49
+ +-------------------+
50
+ |
51
+ v
52
+ +-------------------+
53
+ | AuditLogger |
54
+ | JSON Lines |
55
+ +-------------------+
65
56
  ```
66
57
 
67
58
  ## Decision matrix
@@ -70,98 +61,127 @@ Edge Agent token verification, session fast re-auth, and NAT4 reverse tunnel.
70
61
  |--------|----------|------------|----------------|----------------|----------|
71
62
  | direct | yes | - | - | any | `ALLOW_LOCAL` if bypass token ok, else `DENY` |
72
63
  | direct | no | valid | - | any | `ALLOW_TOKEN`, bind trust |
73
- | direct | no | invalid | - | any | `DENY` |
64
+ | direct | no | invalid/replay | - | any | `DENY` |
74
65
  | 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` |
66
+ | direct | no | - | - | trusted | `DENY` (no downgrade to TOFU) |
67
+ | direct | no | - | - | pending, same fp | `PENDING_TOFU` (heartbeat only) |
68
+ | direct | no | - | - | pending, diff fp | `DENY` (fingerprint tamper prevention) |
69
+ | direct | no | - | - | none | `PENDING_TOFU` (quarantine) |
79
70
  | direct | no | - | - | revoked | `DENY` |
80
71
  | nat4_reverse | no | valid | - | any | `ALLOW_TOKEN` |
81
72
  | nat4_reverse | no | - | - | none | `PENDING_TOFU` |
82
73
 
83
- ## Install
74
+ ## Installation & Quickstart
75
+
76
+ ### Python Package
84
77
 
85
78
  ```bash
86
- pip install edge-agent-commercial
79
+ pip install wimotek-edge-agent
87
80
  ```
88
81
 
89
82
  From source:
90
83
 
91
84
  ```bash
92
- git clone https://github.com/yourorg/edge-agent.git
93
- cd edge-agent
94
- pip install -e ".[dev]"
85
+ git clone https://github.com/wimotek/wimotek-edge-agent.git
86
+ cd wimotek-edge-agent
87
+ pip install -e .
88
+ ```
89
+
90
+ ### Node.js CLI Runner
91
+
92
+ ```bash
93
+ npm install -g wimotek-edge-agent
94
+ # or directly execute:
95
+ npx wimotek-edge-agent --help
95
96
  ```
96
97
 
97
98
  ## Environment variables
98
99
 
99
100
  | Variable | Required | Default | Description |
100
101
  |----------|----------|---------|-------------|
101
- | `EDGE_AGENT_DATA_DIR` | no | `./edge_agent_data` | Data directory for trust DB, audit log, local bypass token |
102
+ | `EDGE_AGENT_DATA_DIR` | no | `./edge_agent_data` | Persistent data directory for trust DB, audit log, local bypass token |
102
103
  | `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 |
104
+ | `EDGE_AGENT_TOKEN_AUDIENCE` | no | `edge-agent` | Token audience claim |
105
+ | `EDGE_AGENT_TOKEN_ISSUER` | no | `central` | Token issuer claim |
106
+ | `EDGE_AGENT_TOKEN_MAX_TTL_SECONDS` | no | `600` | Max token TTL (seconds) |
107
+ | `EDGE_AGENT_SESSION_TICKET_TTL_SECONDS` | no | `300` | Session ticket TTL (seconds) |
107
108
  | `EDGE_AGENT_TOFU_HEARTBEAT_SECONDS` | no | `10` | TOFU quarantine heartbeat interval |
108
109
  | `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. |
110
+ | `EDGE_AGENT_REVERSE_TUNNEL` | no | auto | Force reverse tunnel (`true`/`false`) |
111
+ | `EDGE_AGENT_NAT_TYPE` | no | `unknown` | Network NAT type (`nat4`, `symmetric`, etc.) |
111
112
  | `EDGE_AGENT_NODE_UUID` | reverse only | - | Agent node UUID |
112
113
  | `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 |
114
+ | `EDGE_AGENT_JOIN_TOKEN` | reverse only | - | Join token for reverse tunnel connection |
115
+ | `EDGE_AGENT_TLS_SERVER_NAME` | no | - | TLS SNI hostname |
116
+ | `EDGE_AGENT_CERTIFICATE_PIN_SHA256` | no | - | TLS certificate SHA-256 pin |
116
117
 
117
- Generate a secret:
118
+ Generate a high-entropy secret:
118
119
 
119
120
  ```bash
120
- python -c "import secrets; print(secrets.token_urlsafe(48))"
121
+ wimotek-edge-agent generate-secret
121
122
  ```
122
123
 
123
124
  ## CLI usage
124
125
 
125
- Start a direct TLS JSON-line server:
126
+ Start a direct TLS / TCP JSON-line server:
126
127
 
127
128
  ```bash
128
- edge-agent listen --host 0.0.0.0 --port 8443 \
129
+ wimotek-edge-agent listen --host 0.0.0.0 --port 8443 \
129
130
  --cert /path/server.crt --key /path/server.key
130
131
  ```
131
132
 
132
- Approve a pending TOFU node (admin, out-of-band):
133
+ Approve a pending TOFU node (admin out-of-band verification):
134
+
135
+ ```bash
136
+ wimotek-edge-agent approve --node-uuid node-xxx --fingerprint sha256:...
137
+ ```
138
+
139
+ Revoke a compromised or decommissioned node:
133
140
 
134
141
  ```bash
135
- edge-agent approve --node-uuid node-xxx --fingerprint sha256:...
142
+ wimotek-edge-agent revoke --node-uuid node-xxx
136
143
  ```
137
144
 
138
- Revoke a trusted node:
145
+ List all managed nodes in trust store:
139
146
 
140
147
  ```bash
141
- edge-agent revoke --node-uuid node-xxx
148
+ wimotek-edge-agent list-nodes --status trusted
142
149
  ```
143
150
 
144
- Show a node record:
151
+ Inspect a specific node record:
145
152
 
146
153
  ```bash
147
- edge-agent show-node --node-uuid node-xxx
154
+ wimotek-edge-agent show-node --node-uuid node-xxx
148
155
  ```
149
156
 
150
157
  Issue a single-use Edge Agent join token:
151
158
 
152
159
  ```bash
153
- edge-agent issue-token \
154
- --node-group group-a \
160
+ wimotek-edge-agent issue-token \
161
+ --node-group fleet-a \
155
162
  --ttl 300 \
156
163
  --node-uuid node-xxx \
157
164
  --allowed-ip-cidr 203.0.113.0/24
158
165
  ```
159
166
 
167
+ Verify a token directly:
168
+
169
+ ```bash
170
+ wimotek-edge-agent verify-token --token <token_string> --source-ip 203.0.113.10
171
+ ```
172
+
173
+ Read or generate the local bypass token:
174
+
175
+ ```bash
176
+ wimotek-edge-agent bypass-token
177
+ ```
178
+
160
179
  ## Programmatic usage
161
180
 
162
181
  ```python
163
182
  import asyncio
164
- from edge_agent import Config, EdgeAgent
183
+ from wimotek_edge_agent import Config, EdgeAgent
184
+ from wimotek_edge_agent.transport import JsonLineTransport
165
185
 
166
186
  async def main() -> None:
167
187
  config = Config.from_env()
@@ -177,7 +197,7 @@ async def main() -> None:
177
197
 
178
198
  server = await asyncio.start_server(
179
199
  lambda r, w: agent.handle_connection(
180
- __import__("edge_agent.transport", fromlist=["JsonLineTransport"]).JsonLineTransport(r, w),
200
+ JsonLineTransport(r, w),
181
201
  w.get_extra_info("peername")[0],
182
202
  ),
183
203
  host="0.0.0.0",
@@ -186,13 +206,13 @@ async def main() -> None:
186
206
  async with server:
187
207
  await server.serve_forever()
188
208
 
189
- asyncio.run(main())
209
+ if __name__ == "__main__":
210
+ asyncio.run(main())
190
211
  ```
191
212
 
192
213
  ## Reverse tunnel / NAT4
193
214
 
194
- `transport.TunnelDialer` is a protocol. Implement it with your existing
195
- WebSocket stack, then call:
215
+ Implement `TunnelDialer` with your WebSocket stack, then launch:
196
216
 
197
217
  ```python
198
218
  await agent.run_reverse_tunnel(
@@ -203,19 +223,10 @@ await agent.run_reverse_tunnel(
203
223
  )
204
224
  ```
205
225
 
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.
226
+ - Sends handshake fields (`node_uuid`, `public_key_fingerprint`, `join_token`, `nat_type`, `reverse_tunnel`);
227
+ - Supports TLS pinning via `EDGE_AGENT_CERTIFICATE_PIN_SHA256`;
228
+ - Quarantines in `pending_tofu` until approved by central administrator;
229
+ - Exponential backoff on reconnection attempts.
219
230
 
220
231
  ## Audit log
221
232
 
@@ -225,60 +236,29 @@ Every `AccessDecision` is written to `EDGE_AGENT_DATA_DIR/audit.jsonl`:
225
236
  {"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
237
  ```
227
238
 
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.
239
+ Ship this structured log file to your central logging pipeline (Loki, ELK, CloudWatch, OpenTelemetry). Raw join tokens are never logged.
230
240
 
231
241
  ## Security model
232
242
 
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.
243
+ - **Token Secret**: >= 32 bytes HMAC secret.
244
+ - **Single-Use Replay Protection**: `jti` is stored in SQLite `used_tokens` until expiration. Subsequent attempts are rejected with `token replay detected`.
245
+ - **Identity & Network Binding**: Tokens can be bound to specific `node_uuid` and `allowed_ip_cidr` networks.
246
+ - **TOFU Quarantine**: Unapproved nodes only exchange heartbeats; business data relay is strictly forbidden.
247
+ - **No Downgrade**: Trusted nodes presenting without credentials cannot downgrade to TOFU.
248
+ - **Local Bypass Hardening**: `local_bypass.token` is generated with POSIX `0600` permissions.
249
+ - **Immediate Revocation**: Revoked nodes are denied on the next handshake packet across all network profiles.
249
250
 
250
251
  ## Commercial deployment checklist
251
252
 
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
- ```
253
+ - [ ] Set unique `EDGE_AGENT_TOKEN_SECRET` (>= 32 bytes) per environment.
254
+ - [ ] Mount `EDGE_AGENT_DATA_DIR` on persistent, encrypted storage.
255
+ - [ ] Verify `local_bypass.token` POSIX permissions (`0600`).
256
+ - [ ] Ship `audit.jsonl` to central security SIEM.
257
+ - [ ] Enable TLS; configure `EDGE_AGENT_CERTIFICATE_PIN_SHA256` for reverse tunnels.
258
+ - [ ] Restrict `wimotek-edge-agent approve` to admin-only bastion hosts or CI pipelines.
259
+ - [ ] Monitor `tofu_pending_queue_size` and `token_replay_rejections`.
260
+ - [ ] Execute `wimotek-edge-agent revoke` upon node decommissioning.
261
+ - [ ] Back up `trust_store.sqlite3` prior to upgrades.
282
262
 
283
263
  ## License
284
264
 
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.6",
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,46 @@ 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.6"
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.packages.find]
46
+ where = [
47
+ ".",
48
+ ]
49
+ include = [
50
+ "wimotek_edge_agent*",
51
+ ]
@@ -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
@@ -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