zenctl
v0.3.1
Published
Distributed egress proxy network for OpenCode Zen API rate-limit bypass
Maintainers
Readme
zenctl — OpenCode Zen distributed egress proxy (v2)
A single compiled binary, zenctl, that spreads OpenCode Zen API traffic across
a pool of egress nodes. Run one coordinator on a public host and any number
of nodes on machines whose IPs you want to use for egress. Requests are
routed by tier priority (your own node first, then others, then a direct
fallback), so per-IP rate limits are spread across the pool.
Architecture
cloudflared (TLS, one hostname)
│
OpenCode ──patch baseURL──▶ Coordinator (Bun, one port 9090)
│ • OpenAI API (/v1/chat/completions, /v1/models)
│ • Dashboard (GET /) + SSE logs (/api/logs/stream)
│ • Admin API (/api/admin/*)
│ • WebSocket for nodes (/ws)
▼
┌─────────────┼─────────────┐
Node tier 1 Node tier 2 Node tier 3
│ │ │
└─────────────┼─────────────┘
▼
opencode.ai/zen/v1
(request egresses from a node's IP)Everything runs on one port (Bun serves HTTP + WebSocket together). Nodes
connect out over WebSocket, so they work behind NAT. There is no TUI and no
separate proxy daemon — zenctl node is the only process a node machine runs.
Requirements
- Bun ≥ 1.3
- MongoDB (optional — the coordinator degrades to in-memory storage if absent)
cloudflaredon the coordinator host for public TLS ingress (optional)
Install
# Via npm (requires Bun)
npm install -g zenctl
# Or run directly without installing:
npx zenctl --help
# Or via bun:
bunx zenctl --helpQuick start
# 1. On the coordinator host:
zenctl coordinator --port 9090
# → prints a bootstrap admin key (ak_...) when running without MongoDB
# 2. Mint a node key using the admin API (or `zenctl keys generate`):
curl -X POST http://localhost:9090/api/admin/keys \
-H "Authorization: Bearer ak_..." -H "Content-Type: application/json" \
-d '{"label":"my-laptop","type":"node"}'
# 3. On each node machine:
zenctl node --server ws://localhost:9090/ws --api-key nk_... --tier 1
# 4. On the client machine, point OpenCode at the coordinator:
zenctl patch --server https://ai.example.comCommands
| Command | Purpose | Key flags |
|---|---|---|
| zenctl coordinator | Start the coordinator server | --port, --config, --mongo-uri, --no-cloudflared |
| zenctl node | Start a node client | --server (required), --api-key, --tier (1-3), --data-dir |
| zenctl patch | Point OpenCode's baseURL at the coordinator | --server <url>, --restore, --show |
| zenctl init | Flag-driven setup (writes ~/.config/zenctl/config.json) | --role coordinator\|node, --server, --api-key, --tier, --port, --mongo-uri, --force |
| zenctl keys generate | Generate a node (nk_*) or admin (ak_*) key | --type node\|admin, --label |
| zenctl keys list | List keys (prefix + label only, never the raw key) | |
| zenctl keys revoke | Revoke a key by prefix | --prefix |
Run any command with --help for full details. zenctl --version prints the
version.
Admin API
All /api/admin/* endpoints require an Authorization: Bearer ak_* admin key
and are rate-limited to 10 requests/minute per IP.
| Method & path | Purpose |
|---|---|
| GET /api/admin/status | Version, uptime, node count, rate-limited count, MongoDB status |
| GET /api/admin/nodes | Connected nodes (id, name, tier, status, activeRequests, …) |
| GET /api/admin/keys | List keys (prefix, label, type, createdAt, revoked) |
| POST /api/admin/keys | Create a key { label, type } — returns the raw key once |
| DELETE /api/admin/keys/:prefix | Revoke a key (refuses the last active admin key) |
Raw keys and hashes are never returned by any list endpoint. A freshly created key is the only place the raw value appears, exactly once.
Configuration
Resolution order (later wins): built-in defaults → config file
(ZENCTL_CONFIG path or ~/.config/zenctl/config.json) → ZENCTL_* env vars.
Env vars follow ZENCTL_<SECTION>_<KEY>, e.g.:
| Env var | Overrides |
|---|---|
| ZENCTL_COORDINATOR_PORT | Coordinator listen port (default 9090) |
| ZENCTL_MONGODB_URI | MongoDB connection string |
| ZENCTL_NODE_SERVER_URL | Node's coordinator WebSocket URL |
| ZENCTL_NODE_API_KEY | Node API key |
| ZENCTL_LOGGING_BUFFERSIZE | In-memory log buffer size (default 1000) |
Malformed values fall back to defaults; the loader never throws.
Deployment
- systemd: copy
scripts/opencode-zenctl.service(coordinator) orscripts/opencode-zenctl-node.service(node) to/etc/systemd/system/, thensystemctl enable --now. - cloudflared: see
scripts/cloudflared-config.yml— a single ingress rule routes your hostname tolocalhost:9090(HTTP and WebSocket share the port).
Tests
bun test # full v2 suite
bun run tsc --noEmit # type check