onlsol

HTTP command relay

Community onlsol
Updated

Command relay for remote shells. Push commands from an LLM, CI runner, or any HTTP client to a machine with only curl + bash, in supervised mode with per-command approval or unattended auto mode. No SSH port, agent, or inbound access required.

HTTP command relay

Ephemeral HTTP command relay. Push a shell command from any HTTP client; haveit run on a remote machine that has nothing installed but curl and bash.

Useful whenever you need to bounce a command to a shell without opening an SSHport, installing an agent, or deploying anything:

  • LLM coding sessions driving an arbitrary remote host - any tool, self-hosted or cloud (MCP hosts, curl-literate chat models, local runners, etc.)
  • CI runners poking at post-deploy state
  • Admin shells behind NAT / firewalls
  • Headless devices reachable only outbound

Data on the relay is minimised, not absent: each session gets a smalldirectory holding the pending command/result and a timestamped push history,purged as soon as the idle TTL lapses (default 3h) or on explicit delete.Session keys are 128-bit random.

Architecture

  CLIENT                            RELAY                        REMOTE SHELL
  (LLM / CI / terminal)         (single vhost)                   (curl + bash)

   |                              |                              |
   |-- POST /api/session -------->|                              |
   |<-- key + URLs ---------------|                              |
   |                              |                              |
   |        (user pastes the remote-quickstart on the remote)    |
   |                              |                              |
   |                              |<-- curl /r/KEY?mode=... -----|
   |                              |   runner script starts       |
   |                              |                              |
   |-- POST /cmd-KEY ------------>|      queued on disk          |
   |<-- 201 ----------------------|                              |
   |   (client may disconnect)    |                              |
   |                              |<-- GET /cmd-KEY (poll) ------|
   |                              |-- 200 + command body ------->|
   |                              |                >>> prints cmd|
   |                              |                (y/N if supervised)
   |                              |                bash runs     |
   |                              |                              |
   |                              |<-- POST /result-KEY ---------|
   |                              |      queued on disk          |
   |                              |--- 201 --------------------->|
   |                              |                <<< done      |
   |-- GET /result-KEY (poll) --->|                              |
   |<-- 200 + output -------------|                              |

Stack

Component Role First-party code
nginx Reverse proxy, TLS, rate limiting, static landing page Config only
PHP-FPM Session API, runner script generator, file-backed queue Yes
certbot Automatic TLS issuance + renewal (optional, --profile tls) No

No database. No worker. The only persistent state is a flat-file queue underdata/sessions/<KEY>/ (bind-mounted from the host into the container at/var/data/sessions/<KEY>/), data-minimised and purged on idle TTL orexplicit delete.

Quick start

cp .env.example .env
docker compose up -d

The shipped .env.example defaults to a self-consistent local HTTP setup -no editing required for a first run. After the command above, the API andthe one-liners it hands out are both reachable at http://localhost:49180.

For a public deployment, edit .env first: set DOMAIN to your realhostname, SCHEME=https, NGINX_MODE=tls, HTTP_PORT=80 (required soLet's Encrypt's HTTP-01 challenge is reachable) and HTTPS_PORT=443, then:

docker compose --profile tls up -d         # HTTP + HTTPS with automatic TLS

That's the whole install. Everything else runs in containers.

Environment

All knobs live in .env; nothing is hardcoded.

Var Default Meaning
DOMAIN localhost Public hostname for the API / landing page
SCHEME http http or https - baked into returned one-liners
PUBLIC_PORT 49180 Port baked into generated URLs. Leave empty for the standard port of SCHEME (80/443)
NGINX_MODE http http or tls. tls requires --profile tls
HTTP_PORT / HTTPS_PORT 49180 / 49443 Host ports published by nginx. Public HTTPS deploy: 80 / 443 (80 is required for the ACME HTTP-01 challenge)
RATE_LIMIT 10r/m Per-IP rate on POST /api/session (nginx limit_req syntax)
RATE_LIMIT_BURST 5 Burst slots before 503
PROXY_TIMEOUT 3600 Max seconds either side of a pipe waits for the other end. Must comfortably exceed LONGPOLL_MS
MAX_BODY_SIZE 25m Single source of truth for body-size limits: nginx client_max_body_size on the queue endpoints, and the API derives its raw-body cap (post_max_size), memory limit, its gzip zip-bomb decoded-size cap (3x this value), and the runner's output-truncation cap (80% of this value) from it, so none of them can drift apart. 0 = unlimited at the nginx layer (PHP still keeps a finite decoded cap for safety)
LONGPOLL_MS 15000 Long-poll window (ms) GET /cmd-{key} / GET /result-{key} hold an empty slot open before returning 204, so listeners pick up work sub-second instead of on the next poll tick
FPM_MAX_CHILDREN 64 PHP-FPM pool size. Each active session pins ~2 workers for up to LONGPOLL_MS at a time, so this bounds how many sessions can be live concurrently before other requests start queueing
FPM_MAX_REQUESTS 500 Requests a PHP-FPM worker serves before it is recycled, so no slow leak accumulates over a long uptime. 0 = never recycle
SESSION_TTL 10800 Idle TTL for a session (seconds). Every request on the key resets it; expired sessions are purged with their queue + history.
AUDIT_LOG 0 1 = log key generation to container stderr. Independently of this, nginx's access log masks the key segment of every request line as <key>
SOURCE_URL (empty) Target of the landing page's source link and of GET /source. Empty hides the link
CERTBOT_EMAIL (required for tls profile) Contact address used when requesting certs from the ACME CA
CERTBOT_STAGING 0 1 = use the ACME staging environment (test-only certs, avoids rate limits)

The defaults above are a ready-to-run local HTTP setup - cp .env.example .env && docker compose up -d gives a working roundtrip at http://localhost:49180with no edits. A public HTTPS deploy needs DOMAIN set to your real hostname,SCHEME=https, NGINX_MODE=tls, HTTP_PORT=80 (for the ACME HTTP-01challenge) and HTTPS_PORT=443.

Behind your own reverse proxy? Skip the tls profile. Point your proxy atHTTP_PORT on localhost and terminate TLS there. SCHEME and PUBLIC_PORTdescribe what clients see, so behind a proxy on the standard port they arehttps and 443 (or empty), not the internal HTTP_PORT.

The CI deploy renders .env with scripts/render-env.sh: .env.exampledefaults overridden by same-named CI variables. For a non-localhost DOMAINit refuses SCHEME=http unless ALLOW_PLAIN_HTTP=1, and when SCHEME isgiven without PUBLIC_PORT it uses the standard port of SCHEME, so apublic deploy cannot silently hand out http://DOMAIN:49180/ one-liners.

API

POST /api/session

Generate a new session. Returns:

  • key - 32-char hex, 128-bit entropy
  • ttl_seconds - idle TTL (default 3h sliding; any request on the key resets it)
  • urls.cmd / urls.result - queue push/pop endpoints for this session
  • urls.runner - base URL of the runner script; append ?mode=auto for unattended mode
  • urls.api - /api/session/<key> for re-fetching the payload
  • remote_quickstart - ready-to-paste one-liner, supervised mode (y/N per command)
  • remote_quickstart_auto - the same line with ?mode=auto, for unattended hosts; show operators both and let them pick
  • exec - one-liner template with a COMMAND placeholder for pushing from any HTTP client
  • mcp_messages - agent-facing message templates for the MCP client({version, templates}, from php/app/mcp-messages.json). Serving thewording from the relay means prompt iterations deploy with the relay insteadof requiring an npm re-publish of remotify-mcp; clients keep baked-infallbacks, so the field is optional. Tool names/schemas/descriptions stay inthe npm package deliberately (host approval surface). Omitted if the file ismissing or invalid.

GET /api/session/{key}

Re-fetch the same payload for a known key. Touches the session (resets its idletimer) without consuming anything queued.

GET /api/session/{key}/status

Cheap probe: returns {cmd_queued, result_queued, cmd_in_flight, cmd_in_flight_seconds_ago, listener_seen_seconds_ago} without consuminganything:

  • cmd_queued / result_queued - whether a command/result is currently sitting in the hot slot.
  • cmd_in_flight - a command was picked up by the listener and no result has been pushed back yet.
  • cmd_in_flight_seconds_ago - seconds since that pickup (null when nothing is in flight).
  • listener_seen_seconds_ago - seconds since the listener last polled GET /cmd-{key} (null if it never has).

Used by the MCP server to detect whether the listener has already picked up thequeued command.

POST /api/session/{key}/reset

Recovery endpoint: drops any queued command, any queued result, and clears thein-flight marker, while keeping the session (key, TTL, connected listener)alive. Returns what it actually cleared:{"reset": true, "cleared": {"cmd_queued": ..., "result_queued": ..., "cmd_was_in_flight": ...}}.Rarely needed by hand - the relay self-heals a wedged in-flight marker as soonas the listener reconnects, and the MCP server resets stale state on its own -but useful for scripts and as an operator escape hatch.

DELETE /api/session/{key}

Purges the session directory (queued cmd/result + any archived pushes) andreturns 204.

POST /cmd-{key} / GET /cmd-{key}

POST pushes a command (plain bytes or Content-Encoding: gzip). GET consumesthe hot command slot - used by the listener. Last-write-wins: pushing while acommand is already queued replaces it; the old bytes survive as a timestampedarchive.

GET long-polls: if the slot is empty the PHP worker holds the connectionopen (up to LONGPOLL_MS, default 15s) and returns the instant a command ispushed - sub-second pickup in practice - or 204 once the deadline passes.Pass ?nowait=1 to disable the hold and get an immediate 204/body, usefulfor synchronous probes.

A GET also self-heals a wedged session: polling for new work while acommand is still marked in-flight proves that command's executor is gone (anexecuting listener never polls), so the relay clears the marker and queues asynthetic [remotify: ...] result for whoever is still waiting on it. Alistener that died mid-command (kill -9, closed terminal, reboot) thereforestops blocking the session the moment it - or a replacement - reconnects.

DELETE /cmd-{key}

Drops the hot command slot without consuming (no archive, no listener sideeffects). Used by the MCP server to cancel its own queued command when itgives up waiting for a listener, so the command won't execute as an orphanwhen one eventually connects.

POST /result-{key} / GET /result-{key}

Symmetric endpoints for the listener to post results and the client to fetchthem. GET /result-{key} long-polls exactly like GET /cmd-{key} (sameLONGPOLL_MS window, same ?nowait=1 escape hatch).

GET /r/{key}?mode=supervised|auto

Returns a ready-to-bash runner script. The remote operator runs:

curl -fsSL 'https://relay.remotify.run/r/KEY' | bash              # supervised
curl -fsSL 'https://relay.remotify.run/r/KEY?mode=auto' | bash    # auto

supervised previews every incoming command and waits for y/N (accepts y,Y, yes, etc. - any response starting with y/Y). auto trusts every command(only on hosts where the session key is fully private).

Both modes print each incoming command as >>> <cmd> before running it and<<< done (N bytes pushed back) after the result is posted back, so theoperator can see traffic even in auto mode.

The runner exports a non-interactive env block (DEBIAN_FRONTEND=noninteractive,CI=true, TERM=dumb, PAGER=cat, SYSTEMD_PAGER=cat, GIT_TERMINAL_PROMPT=0)before executing each command, so apt, pagers, and git don't block on TTY input.Sudo still prompts unless the caller uses sudo -n - the operator sees thepreview line and can choose to authenticate.

supervised mode needs an attached terminal to ask y/N; if none is present(nohup, CI, piped in from something other than an interactive shell) therunner exits immediately with a message pointing at ?mode=auto instead ofhanging. The output of any command that exits non-zero gets a trailing[remotify: exit status N] marker so the client can tell success fromfailure; very large output is truncated to a head plus a marker rather thandropped or rejected outright. Result pushes are gzip-compressed when gzipis available on the remote, falling back to a plain POST when it isn't, so abox with only curl + bash still works.

A poll answered 410 (session gone) or 426 (hostname retired) ends therunner with a message, because neither can be fixed by retrying. Every otherpoll failure is retried with a widening wait, 2s doubling up to 30s and resetby the next answered poll, so a relay that is down is not polled - and itserror line not reprinted - every two seconds for as long as the listener isleft running.

GET /api/health

Returns {"ok": true, "service": "<DOMAIN>"}, so a monitor can also confirm which relay answered.

Requests arriving on a retired hostname

A name that still resolves here but is not DOMAIN is answered 426 Upgrade Required on every route, carrying the current endpoint and the fix. Nothing isserved on the old name; clients fail loudly until they are updated.

POST /api/session and GET /api/session/{key} are the exception: they answer200 with a session payload whose quickstart lines and mcp_messagestemplates carry the notice in place of a command. Clients render those anddiscard error bodies, so it is the only way the message reaches an operator.The payload is dead by construction - no session exists behind it, and its URLsuse a scheme no HTTP client can open - so nothing runs.

Skipped entirely when DOMAIN=localhost, so a local stack reached over127.0.0.1 keeps working.

This includes health probes: an uptime monitor that hits the relay by IP orby a hostname other than DOMAIN sees 426, not 200. Point monitors atGET https://DOMAIN/api/health (or send a Host: DOMAIN header).

Landing page

GET / serves a tiny zero-dependency HTML page that calls /api/session,shows the generated key, the remote-side one-liner with a copy button, and thecurl-only exec template you can paste straight into any terminal. Usefulwhen an operator just needs the one-liner without hitting the API manually.

Using it from an LLM tool

Two integration paths. Pick whichever your tool supports:

  • MCP (most coding agents): your tool runs mcp/server.js locally and the model gets a native remote_exec tool.
  • HTTP / curl (any tool with a Bash/shell tool): drop templates/AGENTS.md.example into your repo as the rules file your tool reads; the model calls the API itself with curl.

The MCP server is on npm as remotify-mcp - every host below can use npx to run it with zero install.

Claude Code

claude mcp add -s user remotify -- npx -y remotify-mcp@latest

-s user registers the server globally (in ~/.claude.json at the user scope) so it's available from every working directory. Without -s user it lands in project-local scope and only appears in sessions whose cwd matches where you ran the command.

Verify:

claude mcp list          # remotify should be listed

Inside a session, the /mcp slash-command shows the live status and the exposed remote_exec tool.

Cursor

Edit ~/.cursor/mcp.json:

{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }

Restart Cursor.

Codex CLI (OpenAI)

Edit ~/.codex/config.toml:

[mcp_servers.remotify]
command = "npx"
args    = ["-y", "remotify-mcp@latest"]

OpenCode V2

opencode mcp add remotify --global -- npx -y remotify-mcp@latest

Requires Node.js 18+ with npx. --global registers the server for everyproject in ~/.config/opencode/opencode.jsonc; omit it for the current projectonly. No account, API key, or global npm install is needed.

Check the connection with opencode mcp list or /mcps inside OpenCode.Ask OpenCode to call remote_session_info and show both connection one-liners,then paste your chosen line on the remote. Supervised mode asks for approvalbefore each command; auto mode executes without approval.

Gemini CLI

Edit ~/.gemini/settings.json:

{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }

Any other MCP-capable tool

Windsurf, Continue, Cline/Roo Code, Zed, VS Code's built-in MCP - they all accept the same command + args shape, just in a config file they each document. Drop the snippet in and you're done.

Tools that are NOT MCP-capable (ChatGPT web, Ollama UIs, any bash-only agent, ...)

Drop templates/AGENTS.md.example into your project as the rules file the tool reads - common filenames: AGENTS.md, CLAUDE.md, .cursorrules, CONVENTIONS.md, .github/copilot-instructions.md. It teaches the model to POST /api/session and use curl to push/pull. No server-side integration needed.

Overriding the relay

All snippets above default to https://relay.remotify.run. If you self-host, add one env var:

"env": { "REMOTIFY_URL": "https://remotify.example.com" }

Using it from anything else

If it speaks HTTP, it can talk to the relay. The only API call needed toget started is POST /api/session; everything after that is an HTTP GET orPOST against DOMAIN - there is no separate pipe. subdomain, it's all one vhost.

Security model

  • Key = access. 128-bit random hex, unguessable at internet scale. Keepit private; anyone with the key can push commands to any listener on that key.
  • TLS is mandatory in production. Keys travel in URL paths; HTTPS preventsinterception.
  • nginx rate-limits POST /api/session per IP (see RATE_LIMIT).
  • Data-minimised persistence. Each session's pending command/result andpush history live in a 0700 directory on the relay host, readable only bythe PHP worker user. Kept only as long as needed: purged on the idle TTL(default 3h) or an explicit DELETE.
  • Supervised mode on the remote. /r/KEY?mode=supervised prompts y/Nbefore running each incoming command.
  • --data-raw (rather than plain -d / --data) is used throughout toavoid curl's @file magic - a command that starts with @ will not beread as a filename.

Threat-wise this is roughly equivalent to handing someone an SSH session:commands run as whatever user pasted the remote one-liner. Do not paste theone-liner on a shell you would not SSH into.

License

Apache-2.0. See LICENSE and NOTICE.

MCP Server · Populars

MCP Server · New

    AIAnytime

    Awesome MCP Servers

    A curated directory of Model Context Protocol (MCP) servers, plus runnable reference implementations in Python.

    Community AIAnytime
    wiresage

    MikroTik MCP

    MCP server for Mikrotik

    Community wiresage
    n24q02m

    email

    IMAP/SMTP email for AI agents -- read, send, organize folders, and manage attachments across multiple accounts, with auto-discovery.

    Community n24q02m
    reticlehq

    reticle

    AI agents can generate code, but still struggle to understand what they build. Reticle brings Jev-style machine-native runtime perception to web & desktop applications.

    Community reticlehq
    tubeagentkit

    YouTube MCP Server: Transcripts, Search, Channels & Playlists

    YouTube MCP server: get YouTube video transcripts, search YouTube, browse channels and playlists from Claude, ChatGPT, Cursor and VS Code. Remote, free tier.

    Community tubeagentkit