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 +115 -135
- package/bin/cli.js +9 -4
- package/package.json +13 -7
- package/pyproject.toml +33 -2
- package/wimotek_edge_agent/__main__.py +5 -0
- package/{edge_agent → wimotek_edge_agent}/agent.py +273 -1
- 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/README.md
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
| (
|
|
33
|
+
| (Single Decision Point) |
|
|
44
34
|
external ---> | | ---> ALLOW_TOKEN
|
|
45
|
-
+ token | 1.
|
|
46
|
-
| 2.
|
|
47
|
-
external ---> | 3.
|
|
48
|
-
no token | 4. TOFU
|
|
49
|
-
| 5.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
##
|
|
74
|
+
## Installation & Quickstart
|
|
75
|
+
|
|
76
|
+
### Python Package
|
|
84
77
|
|
|
85
78
|
```bash
|
|
86
|
-
pip install edge-agent
|
|
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/
|
|
93
|
-
cd edge-agent
|
|
94
|
-
pip install -e
|
|
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` |
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
142
|
+
wimotek-edge-agent revoke --node-uuid node-xxx
|
|
136
143
|
```
|
|
137
144
|
|
|
138
|
-
|
|
145
|
+
List all managed nodes in trust store:
|
|
139
146
|
|
|
140
147
|
```bash
|
|
141
|
-
edge-agent
|
|
148
|
+
wimotek-edge-agent list-nodes --status trusted
|
|
142
149
|
```
|
|
143
150
|
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
asyncio.run(main())
|
|
190
211
|
```
|
|
191
212
|
|
|
192
213
|
## Reverse tunnel / NAT4
|
|
193
214
|
|
|
194
|
-
`
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
- [ ]
|
|
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
|
-
```
|
|
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
|
|
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.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
|
-
"
|
|
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,46 @@ 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.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
|
+
]
|
|
@@ -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
|
|
File without changes
|