shell-tunnel
Ultra-lightweight remote shell gateway.
A zero-dependency single binary that exposes command execution on a machine as a structured REST/WebSocket API — typed JSON requests and responses, resource-style sessions — so scripts, tools, and services can drive that machine programmatically.
Not a browser terminal or screen-sharing UI (cf. ttyd, gotty, wetty, sshx), not a remote-desktop product, not a general-purpose tunneling / reverse-proxy product, not a multi-user collaboration surface. The consumer is a program calling an API, not a person at a terminal.
Status. The base platform — sessions, execution, streaming, capability-scoped auth, rate limiting — is implemented. The control-protocol layer (audit trail, self-hosted relay, filesystem guards) is on the roadmap.
How it works
There is no separate server to install: the binary is the server. You run it on the machine you want to control (dev box, build server, container), it listens on an HTTP port, and clients drive that machine by calling the API.
client ──HTTP/WS──▶ shell-tunnel (on the target machine) ──▶ shell
Reachability is your responsibility today. The binary binds a local port; if the target machine sits behind NAT, you still need port forwarding, a VPN, or an SSH/ngrok-style tunnel to reach it. The self-hosted relay that would remove that step is on the roadmap, not implemented.
Install
# From crates.io
# From GitHub Releases (linux-x64 / macos-arm64 / windows-x64)
&&
# From source
Quick Start
On the machine you want to expose, start the listener:
# 1. Local only, no auth — for trying it out on your own machine
# 2. Auth on, key generated for you and printed to the log at startup
# 3. Your own key, reachable from other hosts on port 8080
-k takes any string you choose — treat it like a password, and prefer --require-auth
(which generates one) over inventing a weak one. -H is the bind address: 127.0.0.1
accepts only local connections, 0.0.0.0 accepts connections from other hosts (needed for
remote use — plus whatever forwarding/tunnel gets traffic to that port). Scope the key down
with --preset / --capabilities; see Security.
Then, from the client side:
# {"success":true,"exit_code":0,"output":"Hello World\n","duration_ms":5,"timed_out":false}
Sessions: POST /api/v1/sessions → {"session_id": 1, ...}, then
POST /api/v1/sessions/1/execute, then DELETE /api/v1/sessions/1.
API
Full reference: docs/openapi.json (OpenAPI 3.0, authoritative).
Required capability applies only when auth is enabled; * (full-control) tokens satisfy
every route.
| Method | Endpoint | Capability |
|---|---|---|
GET |
/health |
(none — no auth) |
GET |
/api/v1 |
(authenticated only) |
GET |
/api/v1/sessions |
session.read |
POST |
/api/v1/sessions |
session.manage |
GET |
/api/v1/sessions/{id} |
session.read |
DELETE |
/api/v1/sessions/{id} |
session.manage |
POST |
/api/v1/sessions/{id}/execute |
exec |
POST |
/api/v1/execute |
exec |
WS |
/api/v1/sessions/{id}/ws |
exec |
WS |
/api/v1/ws |
exec |
CLI Options
| Option | Description | Default |
|---|---|---|
-H, --host <ADDR> |
Host address to bind | 127.0.0.1 |
-p, --port <PORT> |
Port to listen on | 3000 |
-c, --config <FILE> |
Path to config file (JSON) | - |
-k, --api-key <KEY> |
API key for authentication | - |
-l, --log-level <LVL> |
error / warn / info / debug / trace | info |
--no-auth |
Disable authentication | false |
--require-auth |
Require auth, auto-generating a key if none given | false |
--capabilities <C> |
Scope issued token(s), e.g. exec,session.read |
full-control |
--preset <NAME> |
Scope by role preset (operator / read-only / full-control) |
full-control |
--no-rate-limit |
Disable rate limiting | false |
--cors-allow-any |
Allow any CORS origin (opt-in; for browser UIs) | false |
--check-update / --update / --no-update-check |
(self-update builds only) | - |
-h, --help / -V, --version |
Help / version | - |
Environment: SHELL_TUNNEL_HOST, SHELL_TUNNEL_PORT, SHELL_TUNNEL_API_KEY,
SHELL_TUNNEL_LOG_LEVEL, RUST_LOG.
Configuration File
Security
Authentication & capabilities. Opaque bearer tokens via Authorization: Bearer <token>;
/health bypasses auth. Auth is off by default — enable with --api-key <KEY>, or
--require-auth to auto-generate (and log) a key at startup.
Each token carries a set of capabilities and each route declares a required one:
exec (run commands), session.read (list/inspect), session.manage (create/delete),
* (wildcard). Missing/invalid token → 401; valid token lacking the capability → 403.
Presets: operator = {exec, session.read, session.manage}, read-only = {session.read},
full-control = {*}.
--capabilities/--preset turn auth on (--no-auth still overrides). Without them a key is
full-control, so existing --api-key setups never hit a 403.
Command content is not filtered. A CommandValidator / path-validator primitive ships in
the crate but is not wired into the server — a token holding exec can run any command
today. The capability token is the primary access control: withhold exec and a token cannot
execute.
Rate limiting. 100 requests/minute per IP by default; X-RateLimit-Limit,
X-RateLimit-Remaining, and Retry-After on a 429.
CORS. Restrictive by default (browser-only mechanism, so no effect on non-browser clients);
opt in with --cors-allow-any or security.cors.allow_any. Note that CORS does not stop
DNS rebinding — Host-header validation is on the roadmap.
Auto-Update
The core binary ships without an update stack. Self-update is an opt-in build feature,
bundled in the official release binaries; from source use
cargo build --release --features self-update.
Such builds check for a newer release on startup and log a notice — nothing is downloaded or installed automatically. Installing is always explicit:
License
MIT — see LICENSE.