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/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/.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/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/.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/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/.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/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/proxy.py b/examples/python-proxy/proxy.py new file mode 100644 index 0000000..e562eaa --- /dev/null +++ b/examples/python-proxy/proxy.py @@ -0,0 +1,213 @@ +""" +GoClaw WebChat Proxy Server — Python (aiohttp) + +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: + pip install -r requirements.txt + cp .env.example .env # fill in GOCLAW_URL and GOCLAW_TOKEN + python proxy.py + +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 +""" + +import asyncio +import json +import os +import signal +import sys + +import aiohttp +from aiohttp import web + +# ── Config ────────────────────────────────────────────────────────────────── + +GOCLAW_URL = os.environ.get("GOCLAW_URL", "") +GOCLAW_TOKEN = os.environ.get("GOCLAW_TOKEN", "") +PORT = int(os.environ.get("PORT", "3100")) +ALLOWED_ORIGINS = [ + o.strip() + for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") + if o.strip() +] +PROXY_API_KEY = os.environ.get("PROXY_API_KEY", "") +DEFAULT_AGENT_ID = os.environ.get("DEFAULT_AGENT_ID", "") + +if not GOCLAW_URL: + print("ERROR: GOCLAW_URL environment variable is required") + sys.exit(1) + +if not GOCLAW_TOKEN: + print("WARNING: GOCLAW_TOKEN not set — proxy will connect without authentication") + + +# ── Helpers ───────────────────────────────────────────────────────────────── + +def check_origin(request: web.Request) -> 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