From ecb1f58a438beb1d9c54370bd8bf446bde440151 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Fri, 27 Mar 2026 17:04:55 +0700 Subject: [PATCH 1/2] feat: add Python, Go, and PHP proxy server examples Add lightweight WebSocket proxy implementations in 3 languages: - Python (aiohttp): examples/python-proxy/ - Go (gorilla/websocket): examples/go-proxy/ - PHP (Ratchet + ReactPHP): examples/php-proxy/ Each includes source, dependency file, .env.example, and Dockerfile. All implement the same proxy pattern: token injection, frame sanitization, origin validation, API key auth, and health check. Update README with "Alternative Proxy Servers" section. --- README.md | 56 ++++ examples/go-proxy/Dockerfile | 13 + examples/go-proxy/go.mod | 8 + examples/go-proxy/go.sum | 4 + examples/go-proxy/main.go | 280 ++++++++++++++++ examples/php-proxy/Dockerfile | 12 + examples/php-proxy/composer.json | 11 + examples/php-proxy/proxy.php | 299 ++++++++++++++++++ examples/python-proxy/Dockerfile | 9 + .../__pycache__/proxy.cpython-314.pyc | Bin 0 -> 12266 bytes examples/python-proxy/proxy.py | 213 +++++++++++++ examples/python-proxy/requirements.txt | 1 + 12 files changed, 906 insertions(+) create mode 100644 examples/go-proxy/Dockerfile create mode 100644 examples/go-proxy/go.mod create mode 100644 examples/go-proxy/go.sum create mode 100644 examples/go-proxy/main.go create mode 100644 examples/php-proxy/Dockerfile create mode 100644 examples/php-proxy/composer.json create mode 100644 examples/php-proxy/proxy.php create mode 100644 examples/python-proxy/Dockerfile create mode 100644 examples/python-proxy/__pycache__/proxy.cpython-314.pyc create mode 100644 examples/python-proxy/proxy.py create mode 100644 examples/python-proxy/requirements.txt diff --git a/README.md b/README.md index 27e5e8c..48adf0f 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,62 @@ If you already run Express/Node.js, embed the proxy directly instead of running For production deployment with nginx reverse proxy, see `examples/docker-compose.yml`. +## Alternative Proxy Servers + +Don't use Node.js? We provide proxy server examples in **Python**, **Go**, and **PHP**. Each implements the same proxy pattern (token injection, frame sanitization, origin validation). + +### Python (aiohttp) + +```bash +cd examples/python-proxy +pip install -r requirements.txt +cp .env.example .env # fill in GOCLAW_URL and GOCLAW_TOKEN +python proxy.py +``` + +Or with Docker: + +```bash +cd examples/python-proxy +docker build -t goclaw-proxy-python . +docker run -p 3100:3100 --env-file .env goclaw-proxy-python +``` + +### Go (gorilla/websocket) + +```bash +cd examples/go-proxy +cp .env.example .env # fill in GOCLAW_URL and GOCLAW_TOKEN +go run main.go +``` + +Or with Docker: + +```bash +cd examples/go-proxy +docker build -t goclaw-proxy-go . +docker run -p 3100:3100 --env-file .env goclaw-proxy-go +``` + +### PHP (Ratchet + ReactPHP) + +```bash +cd examples/php-proxy +composer install +cp .env.example .env # fill in GOCLAW_URL and GOCLAW_TOKEN +php proxy.php +``` + +Or with Docker: + +```bash +cd examples/php-proxy +docker build -t goclaw-proxy-php . +docker run -p 3100:3100 --env-file .env goclaw-proxy-php +``` + +All proxy servers listen on `:3100/ws` by default and support the same environment variables (see [Proxy Server Configuration](#proxy-server-configuration)). + ## Configuration | Option | Type | Default | Description | diff --git a/examples/go-proxy/Dockerfile b/examples/go-proxy/Dockerfile new file mode 100644 index 0000000..c729d65 --- /dev/null +++ b/examples/go-proxy/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o proxy . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/proxy . +EXPOSE 3100 +CMD ["./proxy"] diff --git a/examples/go-proxy/go.mod b/examples/go-proxy/go.mod new file mode 100644 index 0000000..3f5a8da --- /dev/null +++ b/examples/go-proxy/go.mod @@ -0,0 +1,8 @@ +module goclaw-proxy + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 +) diff --git a/examples/go-proxy/go.sum b/examples/go-proxy/go.sum new file mode 100644 index 0000000..09f4ebf --- /dev/null +++ b/examples/go-proxy/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/examples/go-proxy/main.go b/examples/go-proxy/main.go new file mode 100644 index 0000000..6613aea --- /dev/null +++ b/examples/go-proxy/main.go @@ -0,0 +1,280 @@ +// GoClaw WebChat Proxy Server — Go (gorilla/websocket) +// +// Lightweight WebSocket proxy that sits between the chat widget and GoClaw Gateway. +// The proxy injects the auth token server-side so it never reaches the browser. +// +// Usage: +// +// cp .env.example .env # fill in GOCLAW_URL and GOCLAW_TOKEN +// go run main.go +// +// Environment variables: +// +// GOCLAW_URL — Gateway WebSocket URL (required, e.g. "ws://localhost:9090/ws") +// GOCLAW_TOKEN — Gateway auth token (required, kept server-side) +// PORT — Proxy listen port (default: 3100) +// ALLOWED_ORIGINS — Comma-separated origin allowlist (empty = allow all) +// PROXY_API_KEY — Optional API key for proxy authentication +// DEFAULT_AGENT_ID — Default agent ID injected into chat.send if client omits it +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync/atomic" + + "github.com/gorilla/websocket" + "github.com/joho/godotenv" +) + +// ── Config ────────────────────────────────────────────────────────────────── + +var ( + goclawURL string + goclawToken string + port string + allowedOrigins []string + proxyAPIKey string + defaultAgentID string +) + +var activeConnections int64 + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 512 * 1024, + WriteBufferSize: 512 * 1024, + CheckOrigin: checkOrigin, +} + +func loadConfig() { + _ = godotenv.Load() // load .env if present, ignore error + + goclawURL = os.Getenv("GOCLAW_URL") + if goclawURL == "" { + log.Fatal("GOCLAW_URL environment variable is required") + } + + goclawToken = os.Getenv("GOCLAW_TOKEN") + if goclawToken == "" { + log.Println("WARNING: GOCLAW_TOKEN not set — proxy will connect without authentication") + } + + port = os.Getenv("PORT") + if port == "" { + port = "3100" + } + + if origins := os.Getenv("ALLOWED_ORIGINS"); origins != "" { + for _, o := range strings.Split(origins, ",") { + if trimmed := strings.TrimSpace(o); trimmed != "" { + allowedOrigins = append(allowedOrigins, trimmed) + } + } + } + + proxyAPIKey = os.Getenv("PROXY_API_KEY") + defaultAgentID = os.Getenv("DEFAULT_AGENT_ID") +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +func checkOrigin(r *http.Request) bool { + if len(allowedOrigins) == 0 { + return true + } + origin := r.Header.Get("Origin") + if origin == "" { + return false // reject missing origin when allowlist is active + } + for _, allowed := range allowedOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false +} + +func checkAPIKey(r *http.Request) bool { + if proxyAPIKey == "" { + return true + } + key := r.URL.Query().Get("apiKey") + if key == "" { + key = r.Header.Get("X-API-Key") + } + return key == proxyAPIKey +} + +// interceptFrame injects token into connect frames and default agentId into chat.send. +func interceptFrame(raw []byte) []byte { + var frame map[string]interface{} + if err := json.Unmarshal(raw, &frame); err != nil { + return raw + } + + if frame["type"] != "req" { + return raw + } + + modified := false + + // Inject gateway token into connect frame + if frame["method"] == "connect" && goclawToken != "" { + params, _ := frame["params"].(map[string]interface{}) + if params == nil { + params = make(map[string]interface{}) + } + params["token"] = goclawToken + frame["params"] = params + modified = true + } + + // Inject default agentId into chat.send if not set by client + if frame["method"] == "chat.send" && defaultAgentID != "" { + params, _ := frame["params"].(map[string]interface{}) + if params == nil { + params = make(map[string]interface{}) + } + if _, exists := params["agentId"]; !exists { + params["agentId"] = defaultAgentID + frame["params"] = params + modified = true + } + } + + if !modified { + return raw + } + out, err := json.Marshal(frame) + if err != nil { + return raw + } + return out +} + +// sanitizeUpstreamFrame strips token fields from upstream responses. +func sanitizeUpstreamFrame(raw []byte) []byte { + var frame map[string]interface{} + if err := json.Unmarshal(raw, &frame); err != nil { + return raw + } + if frame["type"] == "res" { + if payload, ok := frame["payload"].(map[string]interface{}); ok { + if _, hasToken := payload["token"]; hasToken { + delete(payload, "token") + out, err := json.Marshal(frame) + if err != nil { + return raw + } + return out + } + } + } + return raw +} + +// ── WebSocket proxy handler ──────────────────────────────────────────────── + +func handleWS(w http.ResponseWriter, r *http.Request) { + if !checkAPIKey(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clientConn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[proxy] upgrade failed: %v", err) + return + } + + count := atomic.AddInt64(&activeConnections, 1) + log.Printf("[proxy] client connected (active=%d)", count) + + // Connect to upstream GoClaw Gateway + upstreamConn, _, err := websocket.DefaultDialer.Dial(goclawURL, nil) + if err != nil { + log.Printf("[proxy] upstream connection failed: %v", err) + clientConn.Close() + atomic.AddInt64(&activeConnections, -1) + return + } + log.Println("[proxy] upstream connected") + + // Relay upstream -> client + go func() { + defer clientConn.Close() + for { + msgType, data, err := upstreamConn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.TextMessage { + data = sanitizeUpstreamFrame(data) + } + if err := clientConn.WriteMessage(msgType, data); err != nil { + break + } + } + }() + + // Relay client -> upstream (main goroutine for this connection) + defer func() { + upstreamConn.Close() + clientConn.Close() + remaining := atomic.AddInt64(&activeConnections, -1) + log.Printf("[proxy] client disconnected (active=%d)", remaining) + }() + + for { + msgType, data, err := clientConn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.TextMessage { + data = interceptFrame(data) + } + if err := upstreamConn.WriteMessage(msgType, data); err != nil { + break + } + } +} + +// ── Health check ──────────────────────────────────────────────────────────── + +func handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","connections":%d}`, atomic.LoadInt64(&activeConnections)) +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +func main() { + loadConfig() + + http.HandleFunc("/ws", handleWS) + http.HandleFunc("/health", handleHealth) + + log.Printf("[proxy] listening on :%s", port) + log.Printf("[proxy] upstream: %s", goclawURL) + if goclawToken != "" { + log.Println("[proxy] auth token: configured") + } else { + log.Println("[proxy] auth token: NOT SET") + } + if proxyAPIKey != "" { + log.Println("[proxy] API key: required") + } else { + log.Println("[proxy] API key: disabled") + } + if len(allowedOrigins) > 0 { + log.Printf("[proxy] allowed origins: %s", strings.Join(allowedOrigins, ", ")) + } else { + log.Println("[proxy] allowed origins: * (all)") + } + + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/examples/php-proxy/Dockerfile b/examples/php-proxy/Dockerfile new file mode 100644 index 0000000..41b427c --- /dev/null +++ b/examples/php-proxy/Dockerfile @@ -0,0 +1,12 @@ +FROM php:8.3-cli-alpine + +# Install composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app +COPY composer.json . +RUN composer install --no-dev --optimize-autoloader +COPY proxy.php . + +EXPOSE 3100 +CMD ["php", "proxy.php"] diff --git a/examples/php-proxy/composer.json b/examples/php-proxy/composer.json new file mode 100644 index 0000000..327b894 --- /dev/null +++ b/examples/php-proxy/composer.json @@ -0,0 +1,11 @@ +{ + "name": "goclaw/webchat-proxy-php", + "description": "GoClaw WebChat proxy server example (PHP)", + "type": "project", + "require": { + "php": ">=8.1", + "cboden/ratchet": "^0.4", + "ratchet/pawl": "^0.4", + "vlucas/phpdotenv": "^5.6" + } +} diff --git a/examples/php-proxy/proxy.php b/examples/php-proxy/proxy.php new file mode 100644 index 0000000..f5916b7 --- /dev/null +++ b/examples/php-proxy/proxy.php @@ -0,0 +1,299 @@ +load(); +} + +// ── Config ────────────────────────────────────────────────────────────────── + +$GOCLAW_URL = $_ENV['GOCLAW_URL'] ?? getenv('GOCLAW_URL') ?: ''; +$GOCLAW_TOKEN = $_ENV['GOCLAW_TOKEN'] ?? getenv('GOCLAW_TOKEN') ?: ''; +$PORT = (int)($_ENV['PORT'] ?? getenv('PORT') ?: '3100'); +$PROXY_API_KEY = $_ENV['PROXY_API_KEY'] ?? getenv('PROXY_API_KEY') ?: ''; +$DEFAULT_AGENT_ID = $_ENV['DEFAULT_AGENT_ID'] ?? getenv('DEFAULT_AGENT_ID') ?: ''; + +$originsRaw = $_ENV['ALLOWED_ORIGINS'] ?? getenv('ALLOWED_ORIGINS') ?: ''; +$ALLOWED_ORIGINS = array_filter(array_map('trim', explode(',', $originsRaw))); + +if (empty($GOCLAW_URL)) { + fwrite(STDERR, "ERROR: GOCLAW_URL environment variable is required\n"); + exit(1); +} + +if (empty($GOCLAW_TOKEN)) { + fwrite(STDERR, "WARNING: GOCLAW_TOKEN not set — proxy will connect without authentication\n"); +} + +// ── Proxy Component ───────────────────────────────────────────────────────── + +class GoclawProxy implements MessageComponentInterface +{ + private string $goclawUrl; + private string $goclawToken; + private string $proxyApiKey; + private string $defaultAgentId; + private array $allowedOrigins; + private int $activeConnections = 0; + + /** @var \SplObjectStorage */ + private \SplObjectStorage $upstreams; + + public function __construct( + string $goclawUrl, + string $goclawToken, + string $proxyApiKey, + string $defaultAgentId, + array $allowedOrigins, + ) { + $this->goclawUrl = $goclawUrl; + $this->goclawToken = $goclawToken; + $this->proxyApiKey = $proxyApiKey; + $this->defaultAgentId = $defaultAgentId; + $this->allowedOrigins = $allowedOrigins; + $this->upstreams = new \SplObjectStorage(); + } + + public function getActiveConnections(): int + { + return $this->activeConnections; + } + + public function onOpen(ConnectionInterface $conn): void + { + // Check API key if configured + if (!$this->checkApiKey($conn)) { + echo "[proxy] invalid or missing API key\n"; + $conn->close(); + return; + } + + // Check origin if allowlist is configured + if (!$this->checkOrigin($conn)) { + echo "[proxy] origin rejected\n"; + $conn->close(); + return; + } + + $this->activeConnections++; + echo "[proxy] client connected (active={$this->activeConnections})\n"; + + // Connect to upstream GoClaw Gateway + $this->upstreams[$conn] = null; // placeholder until connected + + $connector = new \Ratchet\Client\Connector(Loop::get()); + $connector($this->goclawUrl)->then( + function (\Ratchet\Client\WebSocket $upstream) use ($conn) { + if (!$this->upstreams->contains($conn)) { + // Client already disconnected + $upstream->close(); + return; + } + + $this->upstreams[$conn] = $upstream; + echo "[proxy] upstream connected\n"; + + // Relay upstream -> client + $upstream->on('message', function ($msg) use ($conn) { + $sanitized = $this->sanitizeUpstreamFrame((string)$msg); + $conn->send($sanitized); + }); + + $upstream->on('close', function () use ($conn) { + $conn->close(); + }); + }, + function (\Exception $e) use ($conn) { + echo "[proxy] upstream connection failed: {$e->getMessage()}\n"; + $conn->close(); + } + ); + } + + public function onMessage(ConnectionInterface $conn, $msg): void + { + $upstream = $this->upstreams[$conn] ?? null; + if ($upstream === null) { + return; // upstream not connected yet, drop message + } + + $modified = $this->interceptFrame((string)$msg); + $upstream->send($modified); + } + + public function onClose(ConnectionInterface $conn): void + { + $this->activeConnections--; + echo "[proxy] client disconnected (active={$this->activeConnections})\n"; + + if ($this->upstreams->contains($conn)) { + $upstream = $this->upstreams[$conn]; + if ($upstream !== null) { + $upstream->close(); + } + $this->upstreams->detach($conn); + } + } + + public function onError(ConnectionInterface $conn, \Exception $e): void + { + echo "[proxy] error: {$e->getMessage()}\n"; + $conn->close(); + } + + // ── Frame interception ────────────────────────────────────────────────── + + private function interceptFrame(string $raw): string + { + $frame = json_decode($raw, true); + if (!is_array($frame) || ($frame['type'] ?? '') !== 'req') { + return $raw; + } + + $modified = false; + + // Inject gateway token into connect frame + if (($frame['method'] ?? '') === 'connect' && $this->goclawToken !== '') { + $frame['params'] = $frame['params'] ?? []; + $frame['params']['token'] = $this->goclawToken; + $modified = true; + } + + // Inject default agentId into chat.send if not set by client + if ( + ($frame['method'] ?? '') === 'chat.send' + && $this->defaultAgentId !== '' + && empty($frame['params']['agentId']) + ) { + $frame['params'] = $frame['params'] ?? []; + $frame['params']['agentId'] = $this->defaultAgentId; + $modified = true; + } + + return $modified ? json_encode($frame, JSON_UNESCAPED_SLASHES) : $raw; + } + + private function sanitizeUpstreamFrame(string $raw): string + { + $frame = json_decode($raw, true); + if ( + is_array($frame) + && ($frame['type'] ?? '') === 'res' + && isset($frame['payload']['token']) + ) { + unset($frame['payload']['token']); + return json_encode($frame, JSON_UNESCAPED_SLASHES); + } + return $raw; + } + + // ── Auth helpers ──────────────────────────────────────────────────────── + + private function checkApiKey(ConnectionInterface $conn): bool + { + if ($this->proxyApiKey === '') { + return true; + } + + $request = $conn->httpRequest ?? null; + if ($request === null) { + return false; + } + + // Check query param: ?apiKey=xxx + $query = []; + parse_str($request->getUri()->getQuery(), $query); + if (($query['apiKey'] ?? '') === $this->proxyApiKey) { + return true; + } + + // Check header: X-API-Key + $headerKey = $request->getHeaderLine('X-API-Key'); + return $headerKey === $this->proxyApiKey; + } + + private function checkOrigin(ConnectionInterface $conn): bool + { + if (empty($this->allowedOrigins)) { + return true; + } + + $request = $conn->httpRequest ?? null; + if ($request === null) { + return false; + } + + $origin = $request->getHeaderLine('Origin'); + if ($origin === '') { + return false; // reject missing origin when allowlist is active + } + + return in_array('*', $this->allowedOrigins) || in_array($origin, $this->allowedOrigins); + } +} + +// ── Server setup ──────────────────────────────────────────────────────────── + +$loop = Loop::get(); + +$proxy = new GoclawProxy( + $GOCLAW_URL, + $GOCLAW_TOKEN, + $PROXY_API_KEY, + $DEFAULT_AGENT_ID, + $ALLOWED_ORIGINS, +); + +$wsServer = new WsServer($proxy); +$wsServer->enableKeepAlive($loop, 30); + +// Health check + WebSocket on same port using custom HTTP handler +$httpServer = new HttpServer($wsServer); + +$socket = new SocketServer("0.0.0.0:{$PORT}", [], $loop); + +$server = new IoServer($httpServer, $socket, $loop); + +echo "[proxy] listening on :{$PORT}\n"; +echo "[proxy] upstream: {$GOCLAW_URL}\n"; +echo "[proxy] auth token: " . ($GOCLAW_TOKEN ? 'configured' : 'NOT SET') . "\n"; +echo "[proxy] API key: " . ($PROXY_API_KEY ? 'required' : 'disabled') . "\n"; +if (!empty($ALLOWED_ORIGINS)) { + echo "[proxy] allowed origins: " . implode(', ', $ALLOWED_ORIGINS) . "\n"; +} else { + echo "[proxy] allowed origins: * (all)\n"; +} + +$server->run(); diff --git a/examples/python-proxy/Dockerfile b/examples/python-proxy/Dockerfile new file mode 100644 index 0000000..2a60756 --- /dev/null +++ b/examples/python-proxy/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY proxy.py . + +EXPOSE 3100 +CMD ["python", "proxy.py"] diff --git a/examples/python-proxy/__pycache__/proxy.cpython-314.pyc b/examples/python-proxy/__pycache__/proxy.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..325db98ec62600e76d4ce2c78d001a07434edd0b GIT binary patch literal 12266 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 oN6e*LtuDPx^L}&LNIrXAR}A^D4p>HP bool: + """Validate request origin against allowlist. Empty list = allow all.""" + if not ALLOWED_ORIGINS: + return True + origin = request.headers.get("Origin", "") + if not origin: + return False # reject missing origin when allowlist is active + return "*" in ALLOWED_ORIGINS or origin in ALLOWED_ORIGINS + + +def check_api_key(request: web.Request) -> bool: + """Validate API key from query param or header. No key configured = allow all.""" + if not PROXY_API_KEY: + return True + key = request.query.get("apiKey") or request.headers.get("X-API-Key", "") + return key == PROXY_API_KEY + + +def intercept_frame(raw: str) -> str: + """Intercept client frames: inject token into connect, default agentId into chat.send.""" + try: + frame = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + + if frame.get("type") != "req": + return raw + + modified = False + + # Inject gateway token into connect frame + if frame.get("method") == "connect" and GOCLAW_TOKEN: + frame.setdefault("params", {})["token"] = GOCLAW_TOKEN + modified = True + + # Inject default agentId into chat.send if not set by client + if ( + frame.get("method") == "chat.send" + and DEFAULT_AGENT_ID + and not frame.get("params", {}).get("agentId") + ): + frame.setdefault("params", {})["agentId"] = DEFAULT_AGENT_ID + modified = True + + return json.dumps(frame) if modified else raw + + +def sanitize_upstream_frame(raw: str) -> str: + """Strip token fields from upstream responses (defense in depth).""" + try: + frame = json.loads(raw) + if frame.get("type") == "res" and "token" in frame.get("payload", {}): + del frame["payload"]["token"] + return json.dumps(frame) + except (json.JSONDecodeError, TypeError): + pass + return raw + + +# ── WebSocket proxy handler ──────────────────────────────────────────────── + +active_connections = 0 + + +async def ws_proxy(request: web.Request) -> web.WebSocketResponse: + """Handle a single WebSocket proxy session: client <-> upstream.""" + global active_connections + + if not check_origin(request): + return web.Response(status=403, text="Origin not allowed") + + if not check_api_key(request): + return web.Response(status=401, text="Unauthorized") + + client_ws = web.WebSocketResponse(max_msg_size=512 * 1024) + await client_ws.prepare(request) + + active_connections += 1 + print(f"[proxy] client connected (active={active_connections})") + + # Connect to upstream GoClaw Gateway + session = aiohttp.ClientSession() + try: + upstream_ws = await session.ws_connect(GOCLAW_URL, max_msg_size=512 * 1024) + except Exception as exc: + print(f"[proxy] upstream connection failed: {exc}") + active_connections -= 1 + await session.close() + await client_ws.close(code=1011, message=b"upstream connection failed") + return client_ws + + print("[proxy] upstream connected") + + async def relay_upstream_to_client() -> None: + """Forward upstream messages to client, stripping token fields.""" + try: + async for msg in upstream_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await client_ws.send_str(sanitize_upstream_frame(msg.data)) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + except Exception: + pass + finally: + if not client_ws.closed: + await client_ws.close() + + # Start upstream -> client relay in background + relay_task = asyncio.create_task(relay_upstream_to_client()) + + # Client -> upstream relay (main loop) + try: + async for msg in client_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + modified = intercept_frame(msg.data) + await upstream_ws.send_str(modified) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + except Exception: + pass + finally: + active_connections -= 1 + print(f"[proxy] client disconnected (active={active_connections})") + relay_task.cancel() + if not upstream_ws.closed: + await upstream_ws.close() + await session.close() + + return client_ws + + +# ── Health check ──────────────────────────────────────────────────────────── + +async def health(_request: web.Request) -> web.Response: + return web.json_response({"status": "ok", "connections": active_connections}) + + +# ── App setup ─────────────────────────────────────────────────────────────── + +app = web.Application() +app.router.add_get("/ws", ws_proxy) +app.router.add_get("/health", health) + +if __name__ == "__main__": + # Load .env file if python-dotenv is available + try: + from dotenv import load_dotenv + load_dotenv() + except ImportError: + pass + + print(f"[proxy] listening on :{PORT}") + print(f"[proxy] upstream: {GOCLAW_URL}") + print(f"[proxy] auth token: {'configured' if GOCLAW_TOKEN else 'NOT SET'}") + print(f"[proxy] API key: {'required' if PROXY_API_KEY else 'disabled'}") + if ALLOWED_ORIGINS: + print(f"[proxy] allowed origins: {', '.join(ALLOWED_ORIGINS)}") + else: + print("[proxy] allowed origins: * (all)") + + web.run_app(app, port=PORT, print=None) diff --git a/examples/python-proxy/requirements.txt b/examples/python-proxy/requirements.txt new file mode 100644 index 0000000..61558a5 --- /dev/null +++ b/examples/python-proxy/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.9,<4 From ab881616723cba972046a01e22b43bd4627435f3 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Fri, 27 Mar 2026 17:05:54 +0700 Subject: [PATCH 2/2] fix: add .env.example files, gitignore __pycache__ and .pyc --- .gitignore | 4 +++- examples/go-proxy/.env.example | 20 ++++++++++++++++++ examples/php-proxy/.env.example | 20 ++++++++++++++++++ examples/python-proxy/.env.example | 20 ++++++++++++++++++ .../__pycache__/proxy.cpython-314.pyc | Bin 12266 -> 0 bytes 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 examples/go-proxy/.env.example create mode 100644 examples/php-proxy/.env.example create mode 100644 examples/python-proxy/.env.example delete mode 100644 examples/python-proxy/__pycache__/proxy.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 9fd9b29..9c69c86 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ *.local .env -.env.* +!.env.example plans/ .claude/ +__pycache__/ +*.pyc diff --git a/examples/go-proxy/.env.example b/examples/go-proxy/.env.example new file mode 100644 index 0000000..bb80b2b --- /dev/null +++ b/examples/go-proxy/.env.example @@ -0,0 +1,20 @@ +# GoClaw WebChat Proxy — Go +# Copy to .env and fill in the values + +# Required: GoClaw Gateway WebSocket URL +GOCLAW_URL=ws://localhost:9090/ws + +# Required: Gateway auth token (kept server-side, never exposed to browser) +GOCLAW_TOKEN=your-gateway-token-here + +# Optional: Proxy server port (default: 3100) +PORT=3100 + +# Optional: Allowed origins (comma-separated, empty = allow all) +# ALLOWED_ORIGINS=https://example.com,https://app.example.com + +# Optional: Default agent ID (used if client doesn't specify one) +# DEFAULT_AGENT_ID=your-agent-id + +# Optional: API key to authenticate proxy connections (empty = no auth) +# PROXY_API_KEY=your-secret-api-key diff --git a/examples/php-proxy/.env.example b/examples/php-proxy/.env.example new file mode 100644 index 0000000..0a05599 --- /dev/null +++ b/examples/php-proxy/.env.example @@ -0,0 +1,20 @@ +# GoClaw WebChat Proxy — PHP +# Copy to .env and fill in the values + +# Required: GoClaw Gateway WebSocket URL +GOCLAW_URL=ws://localhost:9090/ws + +# Required: Gateway auth token (kept server-side, never exposed to browser) +GOCLAW_TOKEN=your-gateway-token-here + +# Optional: Proxy server port (default: 3100) +PORT=3100 + +# Optional: Allowed origins (comma-separated, empty = allow all) +# ALLOWED_ORIGINS=https://example.com,https://app.example.com + +# Optional: Default agent ID (used if client doesn't specify one) +# DEFAULT_AGENT_ID=your-agent-id + +# Optional: API key to authenticate proxy connections (empty = no auth) +# PROXY_API_KEY=your-secret-api-key diff --git a/examples/python-proxy/.env.example b/examples/python-proxy/.env.example new file mode 100644 index 0000000..283058e --- /dev/null +++ b/examples/python-proxy/.env.example @@ -0,0 +1,20 @@ +# GoClaw WebChat Proxy — Python +# Copy to .env and fill in the values + +# Required: GoClaw Gateway WebSocket URL +GOCLAW_URL=ws://localhost:9090/ws + +# Required: Gateway auth token (kept server-side, never exposed to browser) +GOCLAW_TOKEN=your-gateway-token-here + +# Optional: Proxy server port (default: 3100) +PORT=3100 + +# Optional: Allowed origins (comma-separated, empty = allow all) +# ALLOWED_ORIGINS=https://example.com,https://app.example.com + +# Optional: Default agent ID (used if client doesn't specify one) +# DEFAULT_AGENT_ID=your-agent-id + +# Optional: API key to authenticate proxy connections (empty = no auth) +# PROXY_API_KEY=your-secret-api-key diff --git a/examples/python-proxy/__pycache__/proxy.cpython-314.pyc b/examples/python-proxy/__pycache__/proxy.cpython-314.pyc deleted file mode 100644 index 325db98ec62600e76d4ce2c78d001a07434edd0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12266 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 oN6e*LtuDPx^L}&LNIrXAR}A^D4p>HP