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

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

calendar_todayschedule5 min read
— Originally published at dev.to

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

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

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

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

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

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

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

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

1 Comment

1 vote
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I Wrote a Script to Fix Audible's Unreadable PDF Filenames

snapsynapseverified - Apr 20

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Dashboard Operasional Armada Rental Mobil dengan Python + FastAPI

Masbadar - Mar 12

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Why “Building in Public” Is Hollowing Out Your Developer Career

Karol Modelskiverified - Jun 18
chevron_left
657 Points8 Badges
Kolkata, Indiaabhrankan.dpdns org
2Posts
0Comments
4Connections
Math & Programming Enthusiast fascinated by almost isosceles Pythagorean triples, code optimization, and ancient Egypt. Let's explore together!

Related Jobs

View all jobs →

Commenters (This Week)

8 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!