| refactor: clean up code, add systemd service, and write comprehensive README

This commit is contained in:
2026-05-19 17:44:14 +03:00
parent 0519ba4908
commit 9660cfa41b
3 changed files with 338 additions and 71 deletions

136
README.md
View File

@@ -1,3 +1,137 @@
# CORSProxy # CORSProxy
CORS proxy for Unsloth Studio API. 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

223
main.py
View File

@@ -1,136 +1,219 @@
#!/usr/bin/env python3 #!/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: Usage:
python cors_proxy.py # proxies to http://127.0.0.1:8888 on port 8080 python main.py # listen :8080, forward to :8888
python cors_proxy.py --target 8000 # proxies to http://127.0.0.1:8000 python main.py --target 8000 # forward to http://127.0.0.1:8000
python cors_proxy.py --listen 9090 # listens on port 9090 python main.py --listen 9090 # listen on port 9090
python cors_proxy.py --target 10.0.0.5:8000 --listen 9000 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 argparse
import json import json
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler 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-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS, GET", "Access-Control-Allow-Methods": "POST, OPTIONS, GET",
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400", "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): class ProxyHandler(BaseHTTPRequestHandler):
target_host = "127.0.0.1" """Handle individual proxy requests.
target_port = 8888
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}" 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) self.send_response(status)
for k, v in CORS_HEADERS.items(): for key, value in CORS_HEADERS.items():
self.send_header(k, v) 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._set_cors(204)
self.end_headers() 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)) content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
target = self._target_url() target = self._target_url()
req = Request(target, data=body, method="POST") req = Request(target, data=body, method="POST")
for h in ("Content-Type", "Authorization"): # Forward Content-Type and Authorization headers
val = self.headers.get(h) for header_name in ("Content-Type", "Authorization"):
if val: value = self.headers.get(header_name)
req.add_header(h, val) if value:
req.add_header(header_name, value)
try: try:
resp = urlopen(req, timeout=60) resp = urlopen(req, timeout=60)
resp_body = resp.read() resp_body = resp.read()
status = resp.status status = resp.status
except URLError as e: except URLError as exc:
detail = f"Upstream server at {self.target_host}:{self.target_port} is unreachable." detail = (
if hasattr(e, "reason"): f"Upstream server at {self.target_host}:{self.target_port} "
detail += f" Reason: {e.reason}" "is unreachable."
self._set_cors(502) )
self.send_header("Content-Type", "application/json") if hasattr(exc, "reason"):
self.end_headers() detail += f" Reason: {exc.reason}"
self.wfile.write(json.dumps({ self._json_response(502, {"error": {"message": detail, "type": "proxy_error"}})
"error": {"message": detail, "type": "proxy_error"}
}).encode())
return return
except Exception as e: except Exception as exc:
self._set_cors(502) self._json_response(
self.send_header("Content-Type", "application/json") 502, {"error": {"message": f"Proxy error: {exc}", "type": "proxy_error"}}
self.end_headers() )
self.wfile.write(json.dumps({
"error": {"message": f"Proxy error: {e}", "type": "proxy_error"}
}).encode())
return return
self._set_cors(status) self._set_cors(status)
ctype = resp.headers.get("Content-Type", "application/json") content_type = resp.headers.get("Content-Type", "application/json")
self.send_header("Content-Type", ctype) self.send_header("Content-Type", content_type)
self.end_headers() self.end_headers()
self.wfile.write(resp_body) self.wfile.write(resp_body)
def log_message(self, fmt, *args): def do_GET(self) -> None:
method = self.command """Serve a health-check endpoint; everything else returns 405."""
path = self.path
print(f" [{method}] {path} -> {self._target_url()}")
def do_GET(self):
if self.path == "/health": if self.path == "/health":
self._set_cors(200) self._json_response(200, {
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({
"status": "ok", "status": "ok",
"proxy_to": f"{self.target_host}:{self.target_port}", "proxy_to": f"{self.target_host}:{self.target_port}",
}).encode()) })
return return
self._set_cors(405) self._set_cors(405)
self.end_headers() self.end_headers()
# ---- logging ----------------------------------------------------------
def main(): def log_message(self, format: str, *args) -> None:
parser = argparse.ArgumentParser(description="CORS proxy for Unsloth Studio") """Print a human-readable log line for every request."""
parser.add_argument( method = self.command
"--target", default="127.0.0.1:8888", path = self.path
help="Unsloth Studio address (default: 127.0.0.1:8888)", 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( 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)", help="Port to listen on (default: 8080)",
) )
args = parser.parse_args() args = parser.parse_args()
host, port_str = args.target, "8888" # Resolve upstream host and port
if ":" in args.target: host, port = parse_target(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)
# Configure handler and start the server
ProxyHandler.target_host = host ProxyHandler.target_host = host
ProxyHandler.target_port = port ProxyHandler.target_port = port
server = HTTPServer(("0.0.0.0", args.listen), ProxyHandler) server = HTTPServer(("0.0.0.0", args.listen), ProxyHandler)
print(f" Unsloth Studio CORS Proxy") banner = (
print(f" ─────────────────────────") " Unsloth Studio CORS Proxy\n"
print(f" Listening on: http://127.0.0.1:{args.listen}") " ─────────────────────────\n"
print(f" Forwarding to: http://{host}:{port}") f" Listening on: http://127.0.0.1:{args.listen}\n"
print(f" Plugin API URL: http://127.0.0.1:{args.listen}") f" Forwarding to: http://{host}:{port}\n"
print(f" Health check: http://127.0.0.1:{args.listen}/health") 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() print()
try: try:

View File

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