zenctl 0.2.0
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 +134 -0
- package/bin/zenctl.js +51 -0
- package/dist/zenctl.js +34398 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# zenctl — OpenCode Zen distributed egress proxy (v2)
|
|
2
|
+
|
|
3
|
+
A single compiled binary, `zenctl`, that spreads OpenCode Zen API traffic across
|
|
4
|
+
a pool of egress nodes. Run one **coordinator** on a public host and any number
|
|
5
|
+
of **nodes** on machines whose IPs you want to use for egress. Requests are
|
|
6
|
+
routed by tier priority (your own node first, then others, then a direct
|
|
7
|
+
fallback), so per-IP rate limits are spread across the pool.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
cloudflared (TLS, one hostname)
|
|
13
|
+
│
|
|
14
|
+
OpenCode ──patch baseURL──▶ Coordinator (Bun, one port 9090)
|
|
15
|
+
│ • OpenAI API (/v1/chat/completions, /v1/models)
|
|
16
|
+
│ • Dashboard (GET /) + SSE logs (/api/logs/stream)
|
|
17
|
+
│ • Admin API (/api/admin/*)
|
|
18
|
+
│ • WebSocket for nodes (/ws)
|
|
19
|
+
▼
|
|
20
|
+
┌─────────────┼─────────────┐
|
|
21
|
+
Node tier 1 Node tier 2 Node tier 3
|
|
22
|
+
│ │ │
|
|
23
|
+
└─────────────┼─────────────┘
|
|
24
|
+
▼
|
|
25
|
+
opencode.ai/zen/v1
|
|
26
|
+
(request egresses from a node's IP)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Everything runs on **one port** (Bun serves HTTP + WebSocket together). Nodes
|
|
30
|
+
connect out over WebSocket, so they work behind NAT. There is no TUI and no
|
|
31
|
+
separate proxy daemon — `zenctl node` is the only process a node machine runs.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- [Bun](https://bun.sh) ≥ 1.3
|
|
36
|
+
- MongoDB (optional — the coordinator degrades to in-memory storage if absent)
|
|
37
|
+
- `cloudflared` on the coordinator host for public TLS ingress (optional)
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Via npm (requires Bun)
|
|
43
|
+
npm install -g zenctl
|
|
44
|
+
|
|
45
|
+
# Or run directly without installing:
|
|
46
|
+
npx zenctl --help
|
|
47
|
+
|
|
48
|
+
# Or via bun:
|
|
49
|
+
bunx zenctl --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 1. On the coordinator host:
|
|
56
|
+
zenctl coordinator --port 9090
|
|
57
|
+
# → prints a bootstrap admin key (ak_...) when running without MongoDB
|
|
58
|
+
|
|
59
|
+
# 2. Mint a node key using the admin API (or `zenctl keys generate`):
|
|
60
|
+
curl -X POST http://localhost:9090/api/admin/keys \
|
|
61
|
+
-H "Authorization: Bearer ak_..." -H "Content-Type: application/json" \
|
|
62
|
+
-d '{"label":"my-laptop","type":"node"}'
|
|
63
|
+
|
|
64
|
+
# 3. On each node machine:
|
|
65
|
+
zenctl node --server ws://localhost:9090/ws --api-key nk_... --tier 1
|
|
66
|
+
|
|
67
|
+
# 4. On the client machine, point OpenCode at the coordinator:
|
|
68
|
+
zenctl patch --server https://ai.example.com
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
| Command | Purpose | Key flags |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `zenctl coordinator` | Start the coordinator server | `--port`, `--config`, `--mongo-uri`, `--no-cloudflared` |
|
|
76
|
+
| `zenctl node` | Start a node client | `--server` (required), `--api-key`, `--tier` (1-3), `--data-dir` |
|
|
77
|
+
| `zenctl patch` | Point OpenCode's `baseURL` at the coordinator | `--server <url>`, `--restore`, `--show` |
|
|
78
|
+
| `zenctl init` | Flag-driven setup (writes `~/.config/zenctl/config.json`) | `--role coordinator\|node`, `--server`, `--api-key`, `--tier`, `--port`, `--mongo-uri`, `--force` |
|
|
79
|
+
| `zenctl keys generate` | Generate a node (`nk_*`) or admin (`ak_*`) key | `--type node\|admin`, `--label` |
|
|
80
|
+
| `zenctl keys list` | List keys (prefix + label only, never the raw key) | |
|
|
81
|
+
| `zenctl keys revoke` | Revoke a key by prefix | `--prefix` |
|
|
82
|
+
|
|
83
|
+
Run any command with `--help` for full details. `zenctl --version` prints the
|
|
84
|
+
version.
|
|
85
|
+
|
|
86
|
+
## Admin API
|
|
87
|
+
|
|
88
|
+
All `/api/admin/*` endpoints require an `Authorization: Bearer ak_*` admin key
|
|
89
|
+
and are rate-limited to 10 requests/minute per IP.
|
|
90
|
+
|
|
91
|
+
| Method & path | Purpose |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `GET /api/admin/status` | Version, uptime, node count, rate-limited count, MongoDB status |
|
|
94
|
+
| `GET /api/admin/nodes` | Connected nodes (id, name, tier, status, activeRequests, …) |
|
|
95
|
+
| `GET /api/admin/keys` | List keys (prefix, label, type, createdAt, revoked) |
|
|
96
|
+
| `POST /api/admin/keys` | Create a key `{ label, type }` — returns the raw key once |
|
|
97
|
+
| `DELETE /api/admin/keys/:prefix` | Revoke a key (refuses the last active admin key) |
|
|
98
|
+
|
|
99
|
+
Raw keys and hashes are never returned by any list endpoint. A freshly created
|
|
100
|
+
key is the only place the raw value appears, exactly once.
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
Resolution order (later wins): built-in defaults → config file
|
|
105
|
+
(`ZENCTL_CONFIG` path or `~/.config/zenctl/config.json`) → `ZENCTL_*` env vars.
|
|
106
|
+
|
|
107
|
+
Env vars follow `ZENCTL_<SECTION>_<KEY>`, e.g.:
|
|
108
|
+
|
|
109
|
+
| Env var | Overrides |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `ZENCTL_COORDINATOR_PORT` | Coordinator listen port (default 9090) |
|
|
112
|
+
| `ZENCTL_MONGODB_URI` | MongoDB connection string |
|
|
113
|
+
| `ZENCTL_NODE_SERVER_URL` | Node's coordinator WebSocket URL |
|
|
114
|
+
| `ZENCTL_NODE_API_KEY` | Node API key |
|
|
115
|
+
| `ZENCTL_LOGGING_BUFFERSIZE` | In-memory log buffer size (default 1000) |
|
|
116
|
+
|
|
117
|
+
Malformed values fall back to defaults; the loader never throws.
|
|
118
|
+
|
|
119
|
+
## Deployment
|
|
120
|
+
|
|
121
|
+
- **systemd:** copy [`scripts/opencode-zenctl.service`](./scripts/opencode-zenctl.service)
|
|
122
|
+
(coordinator) or [`scripts/opencode-zenctl-node.service`](./scripts/opencode-zenctl-node.service)
|
|
123
|
+
(node) to `/etc/systemd/system/`, then `systemctl enable --now`.
|
|
124
|
+
- **cloudflared:** see [`scripts/cloudflared-config.yml`](./scripts/cloudflared-config.yml)
|
|
125
|
+
— a single ingress rule routes your hostname to `localhost:9090` (HTTP and
|
|
126
|
+
WebSocket share the port).
|
|
127
|
+
|
|
128
|
+
## Tests
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
bun test # full v2 suite
|
|
132
|
+
bun run tsc --noEmit # type check
|
|
133
|
+
```
|
|
134
|
+
|
package/bin/zenctl.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// zenctl — Node shim that spawns the Bun runtime to execute the compiled bundle.
|
|
3
|
+
// This allows `npx zenctl` to work on systems with Bun installed.
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const entry = join(__dirname, "..", "dist", "zenctl.js");
|
|
11
|
+
|
|
12
|
+
// Try `bun` first, then `bun.exe` on Windows
|
|
13
|
+
const candidates = process.platform === "win32" ? ["bun", "bun.exe"] : ["bun"];
|
|
14
|
+
|
|
15
|
+
async function findBun() {
|
|
16
|
+
for (const name of candidates) {
|
|
17
|
+
try {
|
|
18
|
+
const { execSync } = await import("node:child_process");
|
|
19
|
+
execSync(name + " --version", { stdio: "ignore" });
|
|
20
|
+
return name;
|
|
21
|
+
} catch {
|
|
22
|
+
// not found, try next
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bun = await findBun();
|
|
29
|
+
|
|
30
|
+
if (!bun) {
|
|
31
|
+
console.error("");
|
|
32
|
+
console.error(" zenctl requires Bun to run.");
|
|
33
|
+
console.error(" Install it: https://bun.sh");
|
|
34
|
+
console.error(" Then try: bunx zenctl");
|
|
35
|
+
console.error("");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const child = spawn(bun, ["run", entry, ...process.argv.slice(2)], {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
env: process.env,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
child.on("exit", (code, signal) => {
|
|
46
|
+
if (signal) {
|
|
47
|
+
process.kill(process.pid, signal);
|
|
48
|
+
} else {
|
|
49
|
+
process.exit(code ?? 0);
|
|
50
|
+
}
|
|
51
|
+
});
|