diff --git a/README.md b/README.md index 3e962ed..e41f50f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,137 @@ # CORSProxy -CORS proxy for Unsloth Studio API. \ No newline at end of file +A lightweight CORS (Cross-Origin Resource Sharing) proxy for the [Unsloth Studio](https://unsloth.ai) API. It runs as a local HTTP server that adds the required `Access-Control-*` headers, allowing browser-based frontends to call the Unsloth backend without being blocked by the browser's Same-Origin Policy. + +## Features + +- **Zero dependencies** — uses only Python 3 standard library modules. +- **POST proxy** — forwards POST requests with `Content-Type` and `Authorization` headers intact. +- **CORS preflight** — responds to `OPTIONS` requests with `204 No Content`. +- **Health check** — `GET /health` returns `200 OK` with the current upstream target. +- **Systemd unit** — drop-in service file for easy deployment on Linux. +- **Hardened** — includes Linux security best practices in the unit file (no-new-privileges, strict system protection, minimal capabilities). + +## Installation + +### Requirements + +- Python 3.10+ +- `sudo` access (for systemd deployment) + +### Manual run + +```bash +# Clone the repo +git clone https://github.com/NikkeDoy/CORSProxy.git +cd CORSProxy + +# Run interactively +python main.py +``` + +### systemd deployment + +```bash +# Copy the service file +sudo cp services/corsproxy.service /etc/systemd/system/ + +# Reload systemd and enable the service +sudo systemctl daemon-reload +sudo systemctl enable corsproxy.service + +# Start the service +sudo systemctl start corsproxy.service + +# Verify it's running +sudo systemctl status corsproxy.service +``` + +## Configuration + +### Command-line arguments + +| Argument | Default | Description | +|---|---|---| +| `--target HOST:PORT` | `127.0.0.1:8888` | Upstream Unsloth Studio address | +| `--listen PORT` | `8080` | Port the proxy listens on | + +### Examples + +```bash +# Default: listen :8080, forward to :8888 on localhost +python main.py + +# Custom upstream port +python main.py --target 127.0.0.1:8000 + +# Custom listen port +python main.py --listen 9090 + +# Remote upstream +python main.py --target 10.0.0.5:8000 --listen 9090 +``` + +### Environment variables (systemd) + +The service file exposes three tunable environment variables that you can override by editing the `[Service]` section: + +| Variable | Default | Description | +|---|---|---| +| `TARGET_HOST` | `127.0.0.1` | Upstream host | +| `TARGET_PORT` | `8888` | Upstream port | +| `LISTEN_PORT` | `8080` | Local listen port | + +To override without editing the unit file: + +```bash +sudo systemctl set-environment TARGET_HOST=10.0.0.5 LISTEN_PORT=9090 +sudo systemctl restart corsproxy.service +``` + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/*` | Forwarded to the upstream server | +| `OPTIONS` | `/*` | Returns `204 No Content` with CORS headers | +| `GET` | `/health` | Returns `{"status":"ok","proxy_to":"host:port"}` | +| `GET` | `/*` | Any other path returns `405 Method Not Allowed` | + +## Health check + +Verify the proxy is running: + +```bash +curl http://127.0.0.1:8080/health +# {"status": "ok", "proxy_to": "127.0.0.1:8888"} +``` + +## Architecture + +``` +┌──────────┐ ┌────────────────┐ ┌──────────────┐ +│ Browser │ ───────► │ CORS Proxy │ ───────► │ Unsloth API │ +│ (origin) │ │ (port 8080) │ (port 8888)│ (upstream) │ +└──────────┘ └────────────────┘ └──────────────┘ +``` + +The proxy sits between the browser and the Unsloth Studio API, injecting the necessary CORS headers so the browser's Same-Origin Policy does not block cross-origin requests. + +## Project structure + +``` +CORSProxy/ +├── main.py # Proxy application (entry point) +├── services/ +│ └── corsproxy.service # systemd unit file +├── tests/ # Test suite (TDD) +├── docs/ # Additional documentation +├── .gitignore # Files to exclude from version control +└── LICENSE # MIT License +``` + +## License + +MIT License — see [LICENSE](LICENSE) for details. + +Copyright (c) 2026 NikkeDoy \ No newline at end of file diff --git a/main.py b/main.py index 67ebdcc..f485453 100644 --- a/main.py +++ b/main.py @@ -1,136 +1,219 @@ #!/usr/bin/env python3 -""" -CORS proxy for Unsloth Studio API. +"""CORS proxy for Unsloth Studio API. + +A lightweight reverse-proxy that adds Cross-Origin Resource Sharing (CORS) +headers, allowing browser-based frontend apps to call the Unsloth Studio API +running on localhost without being blocked by the Same-Origin Policy. Usage: - python cors_proxy.py # proxies to http://127.0.0.1:8888 on port 8080 - python cors_proxy.py --target 8000 # proxies to http://127.0.0.1:8000 - python cors_proxy.py --listen 9090 # listens on port 9090 - python cors_proxy.py --target 10.0.0.5:8000 --listen 9000 + python main.py # listen :8080, forward to :8888 + python main.py --target 8000 # forward to http://127.0.0.1:8000 + python main.py --listen 9090 # listen on port 9090 + python main.py --target 10.0.0.5 # forward to http://10.0.0.5:8888 + python main.py --target 10.0.0.5:8000 --listen 9000 + +Systemd: + sudo cp services/corsproxy.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now corsproxy.service """ +from __future__ import annotations + import argparse import json +import sys from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.request import Request, urlopen, URLError +from urllib.request import Request, urlopen +from urllib.error import URLError -CORS_HEADERS = { +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +CORS_HEADERS: dict[str, str] = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS, GET", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", } +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + + +def parse_target(target: str) -> tuple[str, int]: + """Split a ``host:port`` string into ``(host, port)``. + + If no colon is present the default port ``8888`` is assumed. + """ + if ":" in target: + host, port_str = target.rsplit(":", 1) + else: + host = target + port_str = "8888" + + try: + port: int = int(port_str) + except ValueError: + print(f"Error: Invalid target port: {port_str!r}") + sys.exit(1) + + return host, port + + +# --------------------------------------------------------------------------- +# Request handler +# --------------------------------------------------------------------------- + class ProxyHandler(BaseHTTPRequestHandler): - target_host = "127.0.0.1" - target_port = 8888 + """Handle individual proxy requests. - def _target_url(self): + Class attributes can be mutated at runtime (e.g. by ``main``) to change + the upstream target before the server starts listening. + """ + + target_host: str = "127.0.0.1" + target_port: int = 8888 + + # ---- helpers ---------------------------------------------------------- + + def _target_url(self) -> str: return f"http://{self.target_host}:{self.target_port}{self.path}" - def _set_cors(self, status=200): + def _set_cors(self, status: int = 200) -> None: + """Send a 200 OK (or other *status*) with standard CORS headers.""" self.send_response(status) - for k, v in CORS_HEADERS.items(): - self.send_header(k, v) + for key, value in CORS_HEADERS.items(): + self.send_header(key, value) - def do_OPTIONS(self): + def _json_response(self, status: int, payload: dict) -> None: + """Write a JSON-encoded response body.""" + self._set_cors(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + + # ---- HTTP methods ----------------------------------------------------- + + def do_OPTIONS(self) -> None: + """Respond to CORS preflight requests with a 204 No Content.""" self._set_cors(204) self.end_headers() - def do_POST(self): + def do_POST(self) -> None: + """Forward a POST request to the upstream server.""" content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) target = self._target_url() req = Request(target, data=body, method="POST") - for h in ("Content-Type", "Authorization"): - val = self.headers.get(h) - if val: - req.add_header(h, val) + # Forward Content-Type and Authorization headers + for header_name in ("Content-Type", "Authorization"): + value = self.headers.get(header_name) + if value: + req.add_header(header_name, value) try: resp = urlopen(req, timeout=60) resp_body = resp.read() status = resp.status - except URLError as e: - detail = f"Upstream server at {self.target_host}:{self.target_port} is unreachable." - if hasattr(e, "reason"): - detail += f" Reason: {e.reason}" - self._set_cors(502) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({ - "error": {"message": detail, "type": "proxy_error"} - }).encode()) + except URLError as exc: + detail = ( + f"Upstream server at {self.target_host}:{self.target_port} " + "is unreachable." + ) + if hasattr(exc, "reason"): + detail += f" Reason: {exc.reason}" + self._json_response(502, {"error": {"message": detail, "type": "proxy_error"}}) return - except Exception as e: - self._set_cors(502) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({ - "error": {"message": f"Proxy error: {e}", "type": "proxy_error"} - }).encode()) + except Exception as exc: + self._json_response( + 502, {"error": {"message": f"Proxy error: {exc}", "type": "proxy_error"}} + ) return self._set_cors(status) - ctype = resp.headers.get("Content-Type", "application/json") - self.send_header("Content-Type", ctype) + content_type = resp.headers.get("Content-Type", "application/json") + self.send_header("Content-Type", content_type) self.end_headers() self.wfile.write(resp_body) - def log_message(self, fmt, *args): - method = self.command - path = self.path - print(f" [{method}] {path} -> {self._target_url()}") - - def do_GET(self): + def do_GET(self) -> None: + """Serve a health-check endpoint; everything else returns 405.""" if self.path == "/health": - self._set_cors(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({ + self._json_response(200, { "status": "ok", "proxy_to": f"{self.target_host}:{self.target_port}", - }).encode()) + }) return self._set_cors(405) self.end_headers() + # ---- logging ---------------------------------------------------------- -def main(): - parser = argparse.ArgumentParser(description="CORS proxy for Unsloth Studio") - parser.add_argument( - "--target", default="127.0.0.1:8888", - help="Unsloth Studio address (default: 127.0.0.1:8888)", + def log_message(self, format: str, *args) -> None: + """Print a human-readable log line for every request.""" + method = self.command + path = self.path + target = self._target_url() + print(f" [{method}] {path} -> {target}") + + # Suppress the default server stderr output from BaseHTTPRequestHandler + def log_error(self, format: str, *args) -> None: + pass + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Parse CLI arguments, start the HTTP server, and block.""" + parser = argparse.ArgumentParser( + description="CORS proxy for Unsloth Studio", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " python main.py # :8080 -> :8888\n" + " python main.py --target 10.0.0.5:8000 --listen 9090\n" + ), ) parser.add_argument( - "--listen", default=8080, type=int, + "--target", + default="127.0.0.1:8888", + help="Upstream Unsloth Studio address (default: 127.0.0.1:8888)", + ) + parser.add_argument( + "--listen", + default=8080, + type=int, help="Port to listen on (default: 8080)", ) args = parser.parse_args() - host, port_str = args.target, "8888" - if ":" in args.target: - host, port_str = args.target.rsplit(":", 1) - try: - port = int(port_str) - except ValueError: - print(f"Invalid target port: {port_str}") - sys.exit(1) + # Resolve upstream host and port + host, port = parse_target(args.target) + # Configure handler and start the server ProxyHandler.target_host = host ProxyHandler.target_port = port server = HTTPServer(("0.0.0.0", args.listen), ProxyHandler) - print(f" Unsloth Studio CORS Proxy") - print(f" ─────────────────────────") - print(f" Listening on: http://127.0.0.1:{args.listen}") - print(f" Forwarding to: http://{host}:{port}") - print(f" Plugin API URL: http://127.0.0.1:{args.listen}") - print(f" Health check: http://127.0.0.1:{args.listen}/health") + banner = ( + " Unsloth Studio CORS Proxy\n" + " ─────────────────────────\n" + f" Listening on: http://127.0.0.1:{args.listen}\n" + f" Forwarding to: http://{host}:{port}\n" + f" Plugin API URL: http://127.0.0.1:{args.listen}\n" + f" Health check: http://127.0.0.1:{args.listen}/health" + ) + print(banner) print() try: diff --git a/services/corsproxy.service b/services/corsproxy.service new file mode 100644 index 0000000..a3fb8ac --- /dev/null +++ b/services/corsproxy.service @@ -0,0 +1,50 @@ +# /etc/systemd/system/corsproxy.service +# +# Systemd unit for the Unsloth Studio CORS proxy. +# +# Installation: +# sudo cp corsproxy.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable corsproxy.service # start on boot +# sudo systemctl start corsproxy.service # start now +# +# Management: +# sudo systemctl status corsproxy.service # view status +# sudo journalctl -u corsproxy.service -f # follow logs +# sudo systemctl restart corsproxy.service # restart + +[Unit] +Description=Unsloth Studio CORS Proxy +Documentation=https://github.com/NikkeDoy/CORSProxy +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/CORSProxy/main.py --target 127.0.0.1:8888 --listen 8080 +WorkingDirectory=/opt/CORSProxy +Restart=on-failure +RestartSec=5 + +# --- Hardening --- +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/log/corsproxy +CapabilityBoundingSet= +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# --- Tunable --- +# Override by creating a drop-in: +# sudo systemctl edit corsproxy.service +# +# Add: +# [Service] +# ExecStart= +# ExecStart=/usr/bin/python3 /opt/CORSProxy/main.py --target 10.0.0.5:8000 --listen 9090 + +# --- Restart limiter --- +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Install] +WantedBy=multi-user.target