DEV Community

Cover image for I built a dependency-free Remote Exec Server in Python using BusyBox-style symlinks
Abhrankan Chakrabarti
Abhrankan Chakrabarti

Posted on Edited on

I built a dependency-free Remote Exec Server in Python using BusyBox-style symlinks

I wanted to see how minimal a remote command execution system could be when restricted entirely to the Python standard library. No external dependencies. No frameworks. Just clean, standard networking primitives.

The result: Remote Exec Server & Client — a two-file system (server.py + client.py) with a BusyBox-style symlink architecture.

👉 github.com/foxhackerzdevs/remote-exec-server


The Problem

I run PARI/GP on a remote machine and wanted to pipe expressions to it from my local terminal — without SSH overhead, without installing PARI/GP locally, and without a heavyweight RPC framework.

The simplest possible solution: an HTTP server that accepts a command + stdin, runs it, and returns stdout.


The BusyBox Idea

BusyBox is a single binary that behaves differently depending on the name it's invoked under. ls, cp, mv — all the same binary, different symlinks.

I applied the same pattern to client.py. You create symlinks named after tools installed on the server:

ln -s client.py gp
ln -s client.py python
ln -s client.py node
Enter fullscreen mode Exit fullscreen mode

When you invoke a symlink, client.py reads sys.argv[0] to determine which command to forward. That command, plus any arguments and stdin, gets packed into an HTTP POST request.


How It Works

server.py (streams stdout AND stderr live, concurrently; optional whitelist and TLS)

#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess, urllib.parse, shlex, os, sys, threading, queue, ssl

VERSION = "1.5.0"

# Comma-separated allowed commands via REMOTE_EXEC_ALLOWED env var.
# Empty/unset = allow everything (original default behavior).
_allowed_env = os.environ.get("REMOTE_EXEC_ALLOWED", "")
ALLOWED = {c.strip() for c in _allowed_env.split(",") if c.strip()}

# TLS cert/key paths via env vars. Unset = plain HTTP (original default).
TLS_CERT = os.environ.get("REMOTE_EXEC_TLS_CERT", "")
TLS_KEY  = os.environ.get("REMOTE_EXEC_TLS_KEY", "")

class MyHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length)

        raw_path = urllib.parse.unquote(self.path.lstrip("/"))
        try:
            cmd_parts = shlex.split(raw_path)
        except ValueError as e:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(f"Error: could not parse command: {e}\n".encode())
            return

        if not cmd_parts:
            # e.g. a bare POST to "/" -- used to crash with an uncaught
            # IndexError on the next line instead of failing cleanly.
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b"Error: no command specified.\n")
            return

        cmd_parts[0] = os.path.basename(cmd_parts[0])

        if ALLOWED and cmd_parts[0] not in ALLOWED:
            self.send_response(403)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.end_headers()
            self.wfile.write(f"Error: command '{cmd_parts[0]}' is not allowed.\n".encode())
            return

        try:
            process = subprocess.Popen(
                cmd_parts,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE
            )

            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Transfer-Encoding", "chunked")
            self.end_headers()

            # stdin is written, and stdout/stderr are read, by THREE
            # concurrent threads -- not sequentially. Writing all of
            # stdin first (as a single blocking call, before anything
            # drains stdout/stderr) deadlocks for any command whose
            # input exceeds the OS pipe buffer (~64KB on Linux): the
            # child fills its stdout pipe and blocks since nobody's
            # reading yet, stops consuming stdin, and the write then
            # blocks too. Same deadlock subprocess.communicate() avoids
            # internally by handling all streams at once.
            line_queue = queue.Queue()

            def reader(stream, prefix):
                for line in stream:
                    line_queue.put(prefix + line if prefix else line)
                line_queue.put(None)

            def stdin_writer(proc, data):
                try:
                    if data:
                        proc.stdin.write(data)
                except BrokenPipeError:
                    pass  # process exited without reading all of stdin
                finally:
                    try:
                        proc.stdin.close()
                    except OSError:
                        pass

            t_in = threading.Thread(target=stdin_writer, args=(process, body))
            t_out = threading.Thread(target=reader, args=(process.stdout, b""))
            t_err = threading.Thread(target=reader, args=(process.stderr, b"[stderr] "))
            t_in.start(); t_out.start(); t_err.start()

            done = 0
            while done < 2:
                chunk = line_queue.get()
                if chunk is None:
                    done += 1
                    continue
                self.wfile.write(f"{len(chunk):X}\r\n".encode())
                self.wfile.write(chunk)
                self.wfile.write(b"\r\n")
                self.wfile.flush()

            t_in.join(); t_out.join(); t_err.join()
            process.wait()
            self.wfile.write(b"0\r\n\r\n")
        except FileNotFoundError:
            self.send_response(200)
            self.end_headers()
            self.wfile.write(f"Error: command not found: {cmd_parts[0]}\n".encode())

if __name__ == "__main__":
    if "--version" in sys.argv or "-V" in sys.argv:
        print(f"remote-exec-server {VERSION}")
        sys.exit(0)

    server = HTTPServer(("0.0.0.0", 8000), MyHandler)

    if TLS_CERT and TLS_KEY:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(certfile=TLS_CERT, keyfile=TLS_KEY)
        server.socket = ctx.wrap_socket(server.socket, server_side=True)
        print(f"Serving HTTPS on 0.0.0.0:8000 (cert: {TLS_CERT})")
    else:
        print("Serving HTTP on 0.0.0.0:8000")

    server.serve_forever()
Enter fullscreen mode Exit fullscreen mode

The first streaming version only read stdout live and grabbed stderr after the fact — which meant stderr output on a successful exit was silently dropped, and a subprocess that filled its stderr pipe buffer while only stdout was being drained could deadlock entirely. Two reader threads plus a shared queue fixed both: stdout and stderr now interleave live, with stderr lines prefixed [stderr] so they're distinguishable in the client's terminal.

Update (v1.5.0): that fix covered stdout/stderr, but missed a related deadlock on the stdin side — writing the entire request body to the subprocess in one blocking call, before the reader threads even started, hangs the whole server (it's single-threaded) for any command whose stdin exceeds the OS pipe buffer, roughly 64KB on Linux. A third thread now writes stdin concurrently with the stdout/stderr readers, the same way subprocess.communicate() avoids this internally. v1.5.0 also fixes an uncaught crash on an empty command path (a bare POST / used to throw an IndexError instead of failing cleanly) and adds --version.

client.py (streams the response line by line; optional TLS)

#!/usr/bin/env python3
import http.client, sys, urllib.parse, os, ssl

VERSION = "1.5.0"

# Can't be a --version flag: this script forwards everything it's
# invoked with -- symlink name and all arguments -- to the server as
# the remote command. A local --version flag would be indistinguishable
# from someone legitimately checking a *remote* command's version (e.g.
# `python --version` via a `python` symlink), so it's an env var instead.
if os.environ.get("REMOTE_EXEC_CLIENT_VERSION", "") == "1":
    print(f"remote-exec-client {VERSION}")
    sys.exit(0)

host = "192.168.56.1:8000"

# TLS via env vars. REMOTE_EXEC_TLS_INSECURE skips cert verification —
# only for self-signed certs during local testing.
use_tls = os.environ.get("REMOTE_EXEC_TLS", "") == "1"
insecure = os.environ.get("REMOTE_EXEC_TLS_INSECURE", "") == "1"

if use_tls:
    ctx = ssl.create_default_context()
    if insecure:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    conn = http.client.HTTPSConnection(host, context=ctx)
else:
    conn = http.client.HTTPConnection(host)

# Avoid a trailing %20 in the path when no arguments are given
cmd_str = sys.argv[0]
if len(sys.argv) > 1:
    cmd_str += " " + " ".join(sys.argv[1:])
path = "/" + urllib.parse.quote(cmd_str)

body = sys.stdin.buffer.read()
conn.request("POST", path, body=body, headers={"Content-Type": "text/plain; charset=utf-8"})

response = conn.getresponse()
for line in response:
    sys.stdout.buffer.write(line)
    sys.stdout.buffer.flush()
Enter fullscreen mode Exit fullscreen mode

The protocol

The command and arguments travel in the URL path. Stdin travels in the request body. Stdout comes back in the response body. That's the entire protocol.

POST /gp%20-q HTTP/1.1
Host: 192.168.56.1:8000
Content-Type: text/plain

print(nextprime(100))
Enter fullscreen mode Exit fullscreen mode

In Action

# Start the server on the remote machine
python server.py

# On the client machine
ln -s client.py gp
echo "print(nextprime(100))" | ./gp -q
# → 101
Enter fullscreen mode Exit fullscreen mode

The Elephant in the Room: Security

This maps HTTP payloads directly to a subprocess call. That's RCE by design. The baseline implementation has no authentication, no rate limiting, and TLS is opt-in rather than default.

I built it this way intentionally — as a minimal blueprint that forces the operator to consciously design their own security layer rather than rely on defaults that provide a false sense of security.

Before any real deployment, the README covers:

Command whitelisting: Built in as of v1.3.0 — set an environment variable before starting the server:

REMOTE_EXEC_ALLOWED="gp,python,node" python server.py
Enter fullscreen mode Exit fullscreen mode

Unset or empty allows everything (the original default). Disallowed commands get a 403 before the subprocess is even spawned.

Encryption: Built in as of v1.4.0 — set cert/key paths on the server and a flag on the client:

# Server
REMOTE_EXEC_TLS_CERT=cert.pem REMOTE_EXEC_TLS_KEY=key.pem python server.py

# Client (add REMOTE_EXEC_TLS_INSECURE=1 for self-signed certs during local testing)
REMOTE_EXEC_TLS=1 python client.py
Enter fullscreen mode Exit fullscreen mode

Unset means plain HTTP, same as before. For local testing, generate a throwaway cert with openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost".

Network isolation: Bind to 127.0.0.1 and route through an SSH tunnel or VPN.

Sandboxing: Run the server inside a Docker container with restricted permissions.


What's Next

The core engine is solid. Real-time output streaming shipped in v1.2.0, command whitelisting in v1.3.0, TLS in v1.4.0, and a couple of real bugs — a server-wide stdin deadlock and a crash on malformed requests — got fixed in v1.5.0. The roadmap includes:

  • Native API-key and mTLS authentication
  • Async request handling
  • Pre-configured Docker deployment

Contributions welcome — especially on the security and async fronts.

👉 github.com/foxhackerzdevs/remote-exec-server

Top comments (0)