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.
Top comments (0)