A lightweight, zero-dependency, single-binary tool that lets you run shell commands and manage local server projects remotely from a browser — with real-time streaming output, HMAC-signed session tokens, and a dark-theme chat-style UI.
# Generate a strong secret
export ROVER_SECRET=$(openssl rand -hex 32)
# Start rover
rover serve --addr :2278Open http://localhost:2278 and log in with your secret.
- Session-based — every command gets a unique ID; history survives the browser closing
- Real-time SSE — output streams live as it's generated, line by line
- Disconnect-safe — close the browser mid-command and reconnect to see full output
- Session persistence — completed sessions survive server restarts (saved to
sessions.json) - Command allowlist — restrict which command prefixes are permitted via
--allow - Chat-like UI — dark theme, mobile-first, scrollable command→output history
- Start/stop projects from the browser — Python scripts, Node servers, Go programs, etc.
- Real server validation — registration and every start probe the port (TCP connect, then an HTTP request) until the app actually listens; the URL is only advertised once confirmed
- Truthful, viewer-aware links — a URL renders as a clickable link only when it will work from where you're looking: the proxy link everywhere, a direct link only after rover dialed the app's socket and got a connection, and loopback addresses clickable only in a browser on the rover host itself (informational text on other devices). See Dashboard links
- Web / TCP / task project kinds — HTTP servers get links + proxy; a raw TCP listener gets an honest "TCP · no proxy" chip instead of a proxy link that could only fail; port-less workers/scripts get console-only cards. See Project kinds
- Honest lifecycle — children are reaped on exit (no zombies); a crash shows Failed (exit N), never a stale "Running"
- Safe port conflicts — an occupied port is never freed by a silent kill: rover identifies the listener (PID, name, cmdline) and offers Adopt / confirmed Kill & start / another port
- Adopt running servers — attach rover (tracking + proxy) to an app already listening on the project's port, e.g. one you started by hand
- Live console streaming — view project logs in real time via SSE (bounded in-memory history)
- Reverse proxy (default ON) — Rover can proxy requests to apps bound to
127.0.0.1, making them accessible via Tailscale/LAN without changing app configuration. Proxy ports are stable across restarts (bookmarkable); the proxy stops forwarding the moment the app dies. Toggle per-project in the dashboard. - Persistent registry — projects are saved to
projects_registry.json(git-ignored, atomic 0600 writes) - Clean shutdown — all launched projects are killed when Rover exits
- HMAC-SHA256 session tokens — login returns a signed, time-limited token (24h TTL); the raw secret is never stored in the browser
X-Rover-Secretheader auth — all protected endpoints require the token header- Secret required off-loopback — Rover refuses to start without a secret unless bound to
127.0.0.1; secret-less mode can never expose the host to the network - Proxy auth (
--proxy-auth auto|on|off) — project proxies can require the same rover login via a signed HttpOnly cookie;autokeeps them open on loopback/Tailscale binds (where the network is the boundary) and gates them everywhere else - Rate-limited login — 10 attempts per IP per minute
- Command allowlist —
--allow git,go test,npmblocks everything else, including project start commands - No blind kills — neither project start nor rover's own startup ever
kill -9s a port occupant without explicit confirmation (--takeover-port/ confirmed PID) - Security headers —
X-Frame-Options,X-Content-Type-Options,Content-Security-Policy - Optional TLS —
--tls-cert/--tls-key - Structured audit log — every exec, login, project change, adopt and confirmed kill is logged with IP and timestamp
Grab the latest release from the Releases page.
# Linux/macOS
curl -L https://github.com/ylnhari/rover/releases/latest/download/rover-linux-amd64 -o rover
chmod +x rover
# Verify checksum
sha256sum -c rover-linux-amd64.sha256git clone https://github.com/ylnhari/rover
cd rover
go build -o rover .Requires Go 1.23+ (uses stdlib log/slog, new http.ServeMux patterns).
rover serve [flags]
Flags:
--addr host:port listen address (default: :2278)
--secret string shared secret (or $ROVER_SECRET)
--allow string comma-separated command prefix allowlist (empty = allow all)
--tls-cert path TLS certificate file
--tls-key path TLS private key file
--exec-timeout duration max run time per command (default: 10m)
--max-output int max output bytes per command (default: 1MB)
--projects-dir path projects root directory
--log-format text|json log output format (default: text)
--no-command-guard allow interactive/GUI/stateful commands (default: blocked)
--proxy-auth auto|on|off require rover login for project proxies (default: auto —
off on loopback/Tailscale binds, on elsewhere)
--takeover-port kill whatever holds rover's own port instead of failing
with the occupant's identity (default: fail and name it)
--validation-timeout dur how long registration/start probes wait for the app
to start listening (default: 30s)
# No auth — trusted network only
rover serve
# With secret from env
ROVER_SECRET=$(openssl rand -hex 32) rover serve
# Restrict allowed commands
rover serve --allow "git,go test,npm run,python"
# JSON logs (for log aggregators)
rover serve --log-format json
# TLS
rover serve --tls-cert /etc/ssl/rover.crt --tls-key /etc/ssl/rover.keyEach command in the Terminal tab runs as a separate, non-interactive process on the machine where rover runs — wrapped as cmd /C <command> (Windows) or sh -c <command> (Unix). Worth knowing:
- No input / no TTY. stdin is not connected and no terminal is allocated, so anything that prompts (passwords, confirmations) or needs a terminal —
vim,top,python/nodewith no script,ssh,git rebase -i,git commitwithout-m— would hang until the timeout. rover blocks these by default (see below); you can't type into a running command. - Each command is a fresh shell. Nothing persists between commands —
cd,export,source/venv activation affect only that one run. Chain instead:cd app && npm test. - It runs on the host, not your browser. A command that opens a file/app/browser tab (
start,open,xdg-open,chrome,code,notepad, …) does so on the rover host's desktop — you won't see it, and GUI apps produce no output. Foreground apps block until closed; detached ones keep running on the host. - Terminal commands can't be stopped from the UI. They run until they exit or hit
--exec-timeout(default 10m) or--max-output(default 1MB). For long-running servers/watchers, use the Projects tab (it has start/stop) instead. - Interactive / GUI / stateful commands are blocked by default. rover rejects commands that can't work in this model — interactive programs (editors, REPLs, password prompts), GUI/file/browser launchers, and non-persistent
cd/export/venv-activation — with a clear reason (HTTP422), and the UI flags them as you type. It's a heuristic on the first token, so long-running servers/watchers are not blocked (use the Projects tab for those). Pass--no-command-guardto disable the guard and allow everything.
None of this applies to ordinary non-interactive, log-producing work (builds, tests, scripts, git with flags).
All protected endpoints require the X-Rover-Secret: <token> header, where <token> is the value returned by POST /api/auth. SSE endpoints (/stream) additionally accept ?secret=<token> as a query parameter (browser EventSource cannot set custom headers).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
— | Web UI |
| GET | /ping |
— | Health check |
| GET | /api/auth |
— | Returns {"required": true/false} |
| POST | /api/auth |
— | Login — returns {"token":"…","expires_at":"…"} |
| GET | /api/sessions |
✓ | List all sessions |
| POST | /api/sessions |
✓ | Create and execute a command |
| DELETE | /api/sessions |
✓ | Clear saved command history (running sessions untouched) |
| GET | /api/sessions/{id} |
✓ | Get session detail (stdout, stderr, exit code) |
| GET | /api/sessions/{id}/stream |
✓ | SSE real-time output stream |
| GET | /api/config |
✓ | Get exec timeout and max output |
| PUT | /api/config |
✓ | Update exec timeout and max output |
| GET | /api/projects |
✓ | List registered projects. Per running project: running_url (app-reported, informational), direct_url (present only when socket-verified reachable on rover's interface), proxy_url, kind (web/tcp/task). The response carries an X-Rover-Local-Viewer: 1|0 header telling the client whether this request came from the rover host itself |
| POST | /api/projects |
✓ | Add a project (validates by starting it). port: 0 or omitted registers a port-less task |
| DELETE | /api/projects/{name} |
✓ | Remove a project from the registry |
| GET | /api/projects/dirs |
✓ | List available unregistered directories |
| GET | /api/projects/{name}/files |
✓ | List eligible start files in a directory |
| POST | /api/projects/{name}/start |
✓ | Start a project; 409 + occupant identity on port conflict; accepts {"kill_occupant":true,"confirm_pid":N} |
| POST | /api/projects/{name}/stop |
✓ | Stop a running project (detaches from adopted ones) |
| POST | /api/projects/{name}/adopt |
✓ | Adopt a process already listening on the project's port |
| GET | /api/projects/{name}/stream |
✓ | SSE live console output (ready, exit, proxy lifecycle events) |
| PUT | /api/projects/{name}/proxy |
✓ | Toggle reverse proxy on/off for a project (applies on next start) |
| GET | /api/proxy-cookie |
✓ | Set the signed proxy-auth cookie for this browser |
http://<rover-addr>:<proxy-port>/ |
* | Per-project reverse-proxy listener on a stable dedicated port; gated by the rover cookie when --proxy-auth is on |
Browser ──────────────────────────────────────────────► Rover (single binary)
│ Terminal (chat UI) POST /api/sessions │
│ Projects tab GET /api/sessions/{id}/stream │
│ GET /api/projects │
│ POST /api/projects/{name}/start│
└──────────────── SSE ◄──────────────────────────────── │
│
┌───────────────┤
│ SessionManager│ (in-memory + sessions.json)
│ launcher.Manager │ (projects_registry.json)
└───────────────┘
│
▼
Child Processes
| Package | Responsibility |
|---|---|
cmd/ |
CLI entry point, flag parsing |
internal/server/ |
HTTP server, routes, SSE, web UI, session management |
internal/launcher/ |
Project lifecycle, process management, URL detection |
internal/auth/ |
HMAC-SHA256 signing, token issuance and verification |
internal/protocol/ |
Shared request/response types |
internal/version/ |
Build version info (injected via -ldflags) |
See SECURITY.md for the full threat model and responsible disclosure policy.
The secret gates control: every /api/* endpoint (exec, project start/stop/add,
config). It does not, by itself, gate the data your proxied apps serve — each
proxied project listens on its own port. Two mechanisms cover that gap:
- Proxies bind rover's own interface (the host part of
--addr), so a proxy is never reachable from a network rover itself is not. --proxy-authadditionally requires the rover login (via a signed HttpOnly cookie) before a proxy forwards anything.auto(the default) resolves to off on loopback and Tailscale (100.64.0.0/10) binds — there the network layer already authenticates devices — and on for any other bind (LAN,0.0.0.0). Unauthenticated browsers are redirected to rover's login once per 24 h; API clients can callGET /api/proxy-cookie.
Bind (--addr) |
Who can reach rover & proxies | Guidance |
|---|---|---|
127.0.0.1:2278 |
this machine only | secret optional, proxy auth pointless (auto=off) |
<tailscale-ip>:2278 |
your tailnet devices | recommended for phone→PC; secret required, proxy auth auto=off (WireGuard device auth is the boundary) |
:2278 / LAN IP |
everyone on every network you join | secret required; proxy auth auto=on; rover warns loudly if you force it off |
- Rover refuses to start without a secret when bound off-loopback.
- The
--allowallowlist and the command guard apply to exec commands and project start commands (enforced when a project is added or its command edited). - An occupied port is never freed by a silent kill — not at project start (the UI
asks, with the occupant's PID/name, and the kill is only executed if the PID still
matches) and not at rover's own startup (fails with the occupant's identity unless
you pass
--takeover-port). - A project's URL is only advertised — and its proxy only starts forwarding — after rover has confirmed something is listening on the port; the proxy is torn down the moment the tracked process exits, so it can never forward tailnet traffic to a stranger process that later grabs the port.
- A direct (non-proxy) link is advertised only after rover verified the app's
socket on its own interface by dialing it — and never while
--proxy-authis on: the UI will not present an unauthenticated path around a gate you enabled. - Enable TLS if traffic crosses an untrusted network; rotate the secret periodically (existing tokens and proxy cookies become invalid immediately).
# Run tests (the launcher suite spins up real helper HTTP servers)
go test ./... -v -count=1 -timeout 180s
# Lint (requires golangci-lint)
golangci-lint run ./...
# Build with version
make build VERSION=0.1.0
# Cross-compile all platforms
make dist VERSION=0.1.0Tag the commit and push — the release workflow builds and uploads binaries automatically:
git tag v0.1.0
git push origin v0.1.0- Click "Add Project" in the Projects tab
- Select a directory from the list
- Select a start file (
.py,.sh,.bat,.js,.ts,.go, etc.) and a port — or leave the port empty to register a port-less task (a worker or script with no web UI; see Project kinds) - Click "Validate & Add" — Rover launches the command (with the port passed as
--port <n>, a{port}placeholder substitution, and thePORTenv var) and probes the port until something actually listens (TCP connect, then one HTTP request — any response, even a 4xx, proves it speaks HTTP; a listener that never answers HTTP is classifiedtcp). Timeout:--validation-timeout, default 30 s. Port-less tasks are instead given a short grace run and fail registration only if they die non-zero right away. - On success the project is saved to
projects_registry.json. On failure you get the real reason: the app's exit code and an output tail, or "nothing listened on port N".
The registry file is personal and git-ignored — each installation has its own.
The same probe runs on every start: the status pill only turns Running once the listener is confirmed (until then it is Starting), a crash shows Failed (exit N), and if the registered port is occupied you choose between Adopt (attach to the process already serving there), Kill & start (executed only after you confirm the exact PID), or a one-off alternative port.
Each project in the registry includes a proxy_enabled field (default true). When enabled, Rover allocates a dedicated listener that reverse-proxies to the project's local port. The proxy binds to the same interface Rover itself listens on (the host portion of --addr), so a proxied app is never reachable from a network Rover is not — bind Rover to your Tailscale IP and the proxies follow. The proxy port is allocated once and persisted (proxy_port in the registry), so the proxy URL survives restarts and can be bookmarked on your phone. The proxy only forwards while the tracked process is alive, and can require the rover login (--proxy-auth). The proxy URL is shown in the dashboard next to the running project, with the hostname rewritten to whatever host your browser used to reach rover — so the link works from the tailnet, the LAN, or localhost alike.
Supported extensions: .py .sh .bat .ps1 .js .ts .go .rb .php .pl .lua
Every link in rover's UI is a verified claim, never a guess. A running project card can show up to three address elements:
| Element | Looks like | When it appears | Where it works |
|---|---|---|---|
| Proxy link | ⎐ http://<rover-host>:<proxy-port> (green) |
project is proxy-enabled and speaks HTTP | anywhere rover itself is reachable — phone, laptop, any device. This is the link to bookmark on your phone. |
| Direct link | ↗ http://<rover-host>:<app-port> (blue) |
only after rover dialed the app's socket on its own interface and got a connection — i.e. the app genuinely binds 0.0.0.0 or rover's interface, not just loopback. Never shown while --proxy-auth is on (it would silently bypass the login gate). |
any device that can reach rover's interface |
| Local address | ⌂ http://127.0.0.1:<port> |
the app binds (or reports) loopback only | clickable only when your browser runs on the rover host itself. On any other device it renders as inert grey text marked (host only) — tapping it there would hit that device's own loopback and fail. |
How rover tells the host apart from your phone: not by the URL — the host
machine and a phone may both open the dashboard via the same address (e.g. a
VPN/tailnet IP). Rover checks the TCP source address of each request: a browser
on the host connects from the machine's own IP or loopback, every other device
connects from its own address. (X-Forwarded-For is honored only on loopback
connections — for setups like tailscale serve — so a remote client can't spoof
host status.)
Clickable proxy/direct links are additionally rewritten to the hostname your browser used to reach rover, so the same link works from localhost, the LAN, or a tailnet without per-network configuration.
Which link should you click?
- Phone / any remote device → the green proxy link.
- Browser on the rover host → the ⌂ local link is the shortest path (browser → app directly, no proxy hop); the proxy link works there too.
- Performance: the proxy adds one same-machine loopback hop — well under a millisecond, with streamed bodies and WebSocket/SSE support. Real-world latency is dominated by your network path, not the proxy.
The validation probe classifies every project, and both the UI and the proxy adapt:
| Kind | How it's detected | What you get |
|---|---|---|
web |
listens on its port and answers an HTTP request (any status code counts) | status pill, links, reverse proxy |
tcp |
listens on its port but never answers HTTP (a database, a game server, a custom protocol) | a "TCP · no proxy" chip instead of a proxy link — rover's reverse proxy is HTTP-only and could only return errors for it |
task |
registered without a port (leave the port field empty) | a console-only card: start/stop, live log streaming, exit status. No URL chrome at all. |
Details worth knowing:
webis sticky: one failed HTTP check during a slow boot does not take the proxy down for a project that already proved it speaks HTTP. If atcpproject later answers HTTP, it is upgraded toweband the registry updated.- Projects registered before kinds existed (no
kindfield in the registry) keep the old behavior — always proxied — so upgrading rover changes nothing for an existing installation. - Two port-less tasks never conflict with each other; port uniqueness is only enforced between real ports.
Q: Does Rover store my secret anywhere?
A: No. The raw secret never leaves the server. The browser stores only the signed 24-hour token in sessionStorage (cleared when the tab closes).
Q: Are sessions saved across restarts?
A: Yes — completed sessions are persisted to sessions.json next to the binary. Running sessions are lost on restart.
Q: How do I restrict which commands can run?
A: Use --allow "git,go test,npm". Rover will reject any command that doesn't start with one of those prefixes.
Q: Can I use Rover over the internet?
A: Only with TLS + a strong secret + a firewall or VPN. Read SECURITY.md first.
Q: What happens to running projects when Rover shuts down?
A: On graceful shutdown (Ctrl+C), Rover kills all child processes via taskkill /F /T (Windows) or kill -9 (Unix); adopted projects are detached, not killed. On forced kill (SIGKILL, Stop-Process -Force, taskkill without /T), child processes become orphans — always use graceful shutdown when possible. An orphaned server can be re-attached after restart via Adopt.
Q: The project's port is already in use — what happens?
A: Rover never kills the occupant silently. It identifies the listener (PID, process name, command line) and the UI offers three explicit choices: Adopt it (attach rover's tracking and proxy to the running process), Kill & start (the kill only executes if the PID you confirmed still holds the port), or a one-off alternative port.
Q: Can I run interactive commands (REPLs, editors, password prompts)?
A: No — and rover blocks them by default with an HTTP 422 and a reason, since they'd just hang (no stdin/TTY). Use non-interactive forms (e.g. git commit -m, npm init -y). To override the guard entirely, start rover with --no-command-guard.
Q: Why didn't my cd (or export / venv activate) affect the next command?
A: Every command runs in a fresh shell. Combine them in one command with &&, e.g. cd app && npm test.
Q: I ran a command that opens a browser/app and nothing appeared in my browser.
A: It opened on the machine running rover, not on your device — GUI launches aren't useful over rover. See How commands run.
Q: Which project link should I open — there are two?
A: On a phone or any remote device, the green ⎐ proxy link (bookmarkable — its port is stable across restarts). In a browser on the rover host itself, the ⌂ local link opens the app directly with no proxy hop; the proxy link works there too. See Dashboard links.
Q: Why is the app's 127.0.0.1 address grey text instead of a link on my phone?
A: Because it would not work there: 127.0.0.1 on your phone is the phone itself. Rover shows it as information and makes it clickable only for browsers it verified are running on the rover host (by the request's TCP source address). Use the proxy link from other devices.
Q: Does the reverse proxy slow my apps down?
A: Not noticeably. The extra hop is a same-machine loopback forward (sub-millisecond); bodies are streamed, and SSE/WebSockets pass through. Your Wi-Fi/VPN latency dwarfs it. On the rover host you can skip it entirely via the local link.
Q: Can I register something that isn't a web server — a worker, a script?
A: Yes — leave the port field empty when adding it. It becomes a task: rover starts/stops it and streams its console, with no port probing and no links. See Project kinds.
Q: My app listens on a port but rover shows "TCP · no proxy".
A: The validation probe connected to the port but got no HTTP response, so the app was classified tcp (e.g. a database or custom protocol). Rover's reverse proxy is HTTP-only, so it honestly declines to offer a proxy link. If the app actually is an HTTP server that was just slow to answer during validation, start it once more — the moment a start-time probe sees HTTP it is upgraded to web permanently.