✨ | refactor: clean up code, add systemd service, and write comprehensive README
This commit is contained in:
136
README.md
136
README.md
@@ -1,3 +1,137 @@
|
||||
# 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
223
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:
|
||||
|
||||
50
services/corsproxy.service
Normal file
50
services/corsproxy.service
Normal 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
|
||||
Reference in New Issue
Block a user