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, threading, queue, ssl

# 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("/"))
        cmd_parts = shlex.split(raw_path)
        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
            )
            if body:
                process.stdin.write(body)
            process.stdin.close()

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

            # Two reader threads feed a shared queue so stdout and stderr
            # are forwarded live without either one blocking the other
            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)

            t_out = threading.Thread(target=reader, args=(process.stdout, b""))
            t_err = threading.Thread(target=reader, args=(process.stderr, b"[stderr] "))
            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_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__":
    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.

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

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

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. 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)