add gateway reverse proxy for extra services (#8456)
* feat: add gateway reverse proxy for extra services Add a lightweight Node.js gateway on port 3000 that routes requests by URL prefix (/ws/*, /ws_mp/*, /ws_debug/*) to the correct backend service, stripping the prefix before forwarding. This allows all extra services to be accessed through a single port. Also makes the multiplayer server more tolerant by generically stripping /ws_mp/ prefix on HTTP requests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: enable gateway by default for extra services Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add REMOTE_EXTRA env var for unified extra services proxy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: make gateway port configurable via PORT env var Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: simplify Caddyfile extra services routing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
Caddyfile
14
Caddyfile
@@ -11,18 +11,8 @@
|
|||||||
{$BASE_URL} {
|
{$BASE_URL} {
|
||||||
bind {$ADDRESS}
|
bind {$ADDRESS}
|
||||||
|
|
||||||
# LSP - Language Server Protocol for code intelligence (windmill_extra:3001)
|
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway)
|
||||||
reverse_proxy /ws/* http://windmill_extra:3001
|
reverse_proxy /ws/* /ws_mp/* /ws_debug/* http://windmill_extra:3000
|
||||||
|
|
||||||
# Multiplayer - Real-time collaboration, Enterprise Edition (windmill_extra:3002)
|
|
||||||
# Uncomment and set ENABLE_MULTIPLAYER=true in docker-compose.yml
|
|
||||||
# reverse_proxy /ws_mp/* http://windmill_extra:3002
|
|
||||||
|
|
||||||
# Debugger - Interactive debugging via DAP WebSocket (windmill_extra:3003)
|
|
||||||
# Set ENABLE_DEBUGGER=true in docker-compose.yml to enable
|
|
||||||
handle_path /ws_debug/* {
|
|
||||||
reverse_proxy http://windmill_extra:3003
|
|
||||||
}
|
|
||||||
|
|
||||||
# Search indexer, Enterprise Edition (windmill_indexer:8002)
|
# Search indexer, Enterprise Edition (windmill_indexer:8002)
|
||||||
# reverse_proxy /api/srch/* http://windmill_indexer:8002
|
# reverse_proxy /api/srch/* http://windmill_indexer:8002
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ WORKDIR /multiplayer
|
|||||||
# Copy multiplayer server files
|
# Copy multiplayer server files
|
||||||
COPY multiplayer/package.json .
|
COPY multiplayer/package.json .
|
||||||
COPY multiplayer/server.mjs .
|
COPY multiplayer/server.mjs .
|
||||||
|
COPY multiplayer/gateway.mjs .
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install
|
RUN npm install
|
||||||
@@ -129,13 +130,15 @@ RUN chmod -R a+rX /usr/local && \
|
|||||||
chmod -R a+rX /debugger
|
chmod -R a+rX /debugger
|
||||||
|
|
||||||
# Expose all service ports
|
# Expose all service ports
|
||||||
EXPOSE 3001 3002 3003
|
EXPOSE 3000 3001 3002 3003
|
||||||
|
|
||||||
# Environment variables for service control
|
# Environment variables for service control
|
||||||
ENV ENABLE_LSP=true
|
ENV ENABLE_LSP=true
|
||||||
ENV ENABLE_MULTIPLAYER=true
|
ENV ENABLE_MULTIPLAYER=true
|
||||||
ENV ENABLE_DEBUGGER=true
|
ENV ENABLE_DEBUGGER=true
|
||||||
# nsjail sandboxing for debugger (requires --privileged, off by default)
|
# nsjail sandboxing for debugger (requires --privileged, off by default)
|
||||||
|
ENV ENABLE_GATEWAY=true
|
||||||
|
ENV GATEWAY_PORT=3000
|
||||||
ENV ENABLE_NSJAIL=false
|
ENV ENABLE_NSJAIL=false
|
||||||
|
|
||||||
# LSP port
|
# LSP port
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ echo "[entrypoint] Starting Windmill Extra Services"
|
|||||||
echo "[entrypoint] ENABLE_LSP=${ENABLE_LSP:-true}"
|
echo "[entrypoint] ENABLE_LSP=${ENABLE_LSP:-true}"
|
||||||
echo "[entrypoint] ENABLE_MULTIPLAYER=${ENABLE_MULTIPLAYER:-true}"
|
echo "[entrypoint] ENABLE_MULTIPLAYER=${ENABLE_MULTIPLAYER:-true}"
|
||||||
echo "[entrypoint] ENABLE_DEBUGGER=${ENABLE_DEBUGGER:-true}"
|
echo "[entrypoint] ENABLE_DEBUGGER=${ENABLE_DEBUGGER:-true}"
|
||||||
|
echo "[entrypoint] ENABLE_GATEWAY=${ENABLE_GATEWAY:-true}"
|
||||||
|
|
||||||
# Start LSP service
|
# Start LSP service
|
||||||
if [ "${ENABLE_LSP:-true}" = "true" ]; then
|
if [ "${ENABLE_LSP:-true}" = "true" ]; then
|
||||||
@@ -81,6 +82,15 @@ if [ "${ENABLE_DEBUGGER:-true}" = "true" ]; then
|
|||||||
echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})"
|
echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Start Gateway reverse proxy (routes /ws/*, /ws_mp/*, /ws_debug/* to the right service)
|
||||||
|
if [ "${ENABLE_GATEWAY:-true}" = "true" ]; then
|
||||||
|
echo "[entrypoint] Starting Gateway on port ${GATEWAY_PORT:-3000}..."
|
||||||
|
cd /multiplayer
|
||||||
|
PORT=${GATEWAY_PORT:-3000} node gateway.mjs &
|
||||||
|
PIDS+=($!)
|
||||||
|
echo "[entrypoint] Gateway started (PID: ${PIDS[-1]})"
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if any services were started
|
# Check if any services were started
|
||||||
if [ ${#PIDS[@]} -eq 0 ]; then
|
if [ ${#PIDS[@]} -eq 0 ]; then
|
||||||
echo "[entrypoint] WARNING: No services enabled. Set ENABLE_LSP, ENABLE_MULTIPLAYER, or ENABLE_DEBUGGER to true."
|
echo "[entrypoint] WARNING: No services enabled. Set ENABLE_LSP, ENABLE_MULTIPLAYER, or ENABLE_DEBUGGER to true."
|
||||||
|
|||||||
@@ -58,20 +58,19 @@ const config = {
|
|||||||
cookieDomainRewrite: 'localhost'
|
cookieDomainRewrite: 'localhost'
|
||||||
},
|
},
|
||||||
'^/ws/.*': {
|
'^/ws/.*': {
|
||||||
target: process.env.REMOTE_LSP ?? 'https://app.windmill.dev',
|
target: process.env.REMOTE_LSP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
},
|
},
|
||||||
'^/ws_mp/.*': {
|
'^/ws_mp/.*': {
|
||||||
target: process.env.REMOTE_MP ?? 'https://app.windmill.dev',
|
target: process.env.REMOTE_MP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
},
|
},
|
||||||
'^/ws_debug/.*': {
|
'^/ws_debug/.*': {
|
||||||
target: process.env.REMOTE_DEBUG ?? 'https://app.windmill.dev',
|
target: process.env.REMOTE_DEBUG ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true
|
||||||
rewrite: (path) => path.replace(/^\/ws_debug/, '')
|
|
||||||
},
|
},
|
||||||
'^/ui_builder/.*': {
|
'^/ui_builder/.*': {
|
||||||
target: 'http://localhost:4000',
|
target: 'http://localhost:4000',
|
||||||
|
|||||||
128
multiplayer/gateway.mjs
Normal file
128
multiplayer/gateway.mjs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Lightweight reverse proxy gateway for windmill-extra services.
|
||||||
|
*
|
||||||
|
* Listens on a single port (default 3000) and dispatches requests
|
||||||
|
* to the correct backend service based on URL prefix:
|
||||||
|
*
|
||||||
|
* /ws/* → LSP (default: localhost:3001)
|
||||||
|
* /ws_mp/* → Multiplayer (default: localhost:3002)
|
||||||
|
* /ws_debug/* → Debugger (default: localhost:3003)
|
||||||
|
* everything else → LSP (fallback)
|
||||||
|
*
|
||||||
|
* The prefix is stripped before forwarding so each service receives
|
||||||
|
* clean paths (e.g. /ws_mp/room-name → /room-name on port 3002).
|
||||||
|
*
|
||||||
|
* Handles both HTTP and WebSocket upgrades.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'http'
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || process.env.GATEWAY_PORT || '3000')
|
||||||
|
const HOST = process.env.GATEWAY_HOST || '0.0.0.0'
|
||||||
|
|
||||||
|
const LSP_PORT = parseInt(process.env.LSP_PORT || '3001')
|
||||||
|
const MULTIPLAYER_PORT = parseInt(process.env.MULTIPLAYER_PORT || '3002')
|
||||||
|
const DEBUGGER_PORT = parseInt(process.env.DEBUGGER_PORT || '3003')
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ prefix: '/ws_mp/', target: MULTIPLAYER_PORT, strip: '/ws_mp' },
|
||||||
|
{ prefix: '/ws_mp', target: MULTIPLAYER_PORT, strip: '/ws_mp' },
|
||||||
|
{ prefix: '/ws_debug/', target: DEBUGGER_PORT, strip: '/ws_debug' },
|
||||||
|
{ prefix: '/ws_debug', target: DEBUGGER_PORT, strip: '/ws_debug' },
|
||||||
|
{ prefix: '/ws/', target: LSP_PORT, strip: '' },
|
||||||
|
{ prefix: '/ws', target: LSP_PORT, strip: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolve (url) {
|
||||||
|
for (const r of ROUTES) {
|
||||||
|
if (url === r.prefix || url.startsWith(r.prefix + (r.prefix.endsWith('/') ? '' : '/'))) {
|
||||||
|
const stripped = r.strip ? url.slice(r.strip.length) || '/' : url
|
||||||
|
return { port: r.target, path: stripped }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: forward as-is to LSP
|
||||||
|
return { port: LSP_PORT, path: url }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP proxy ----
|
||||||
|
|
||||||
|
const server = http.createServer((clientReq, clientRes) => {
|
||||||
|
const { port, path } = resolve(clientReq.url)
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
method: clientReq.method,
|
||||||
|
headers: clientReq.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = http.request(opts, (proxyRes) => {
|
||||||
|
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers)
|
||||||
|
proxyRes.pipe(clientRes, { end: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.on('error', (err) => {
|
||||||
|
console.error(`[gateway] HTTP proxy error → :${port}${path}: ${err.message}`)
|
||||||
|
if (!clientRes.headersSent) {
|
||||||
|
clientRes.writeHead(502, { 'Content-Type': 'text/plain' })
|
||||||
|
}
|
||||||
|
clientRes.end('Bad Gateway')
|
||||||
|
})
|
||||||
|
|
||||||
|
clientReq.pipe(proxy, { end: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- WebSocket proxy (upgrade) ----
|
||||||
|
|
||||||
|
server.on('upgrade', (clientReq, clientSocket, head) => {
|
||||||
|
const { port, path } = resolve(clientReq.url)
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
method: 'GET',
|
||||||
|
headers: clientReq.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = http.request(opts)
|
||||||
|
|
||||||
|
proxy.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
||||||
|
// Relay the 101 back to the client
|
||||||
|
let response = `HTTP/1.1 101 Switching Protocols\r\n`
|
||||||
|
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
||||||
|
response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r\n`
|
||||||
|
}
|
||||||
|
response += '\r\n'
|
||||||
|
|
||||||
|
clientSocket.write(response)
|
||||||
|
|
||||||
|
if (proxyHead.length) {
|
||||||
|
clientSocket.write(proxyHead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bi-directional pipe
|
||||||
|
proxySocket.pipe(clientSocket)
|
||||||
|
clientSocket.pipe(proxySocket)
|
||||||
|
|
||||||
|
proxySocket.on('error', () => clientSocket.destroy())
|
||||||
|
clientSocket.on('error', () => proxySocket.destroy())
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.on('error', (err) => {
|
||||||
|
console.error(`[gateway] WS proxy error → :${port}${path}: ${err.message}`)
|
||||||
|
clientSocket.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[gateway] listening on ${HOST}:${PORT}`)
|
||||||
|
console.log(`[gateway] /ws/* → :${LSP_PORT}`)
|
||||||
|
console.log(`[gateway] /ws_mp/* → :${MULTIPLAYER_PORT}`)
|
||||||
|
console.log(`[gateway] /ws_debug/* → :${DEBUGGER_PORT}`)
|
||||||
|
console.log(`[gateway] (fallback) → :${LSP_PORT}`)
|
||||||
|
})
|
||||||
@@ -122,8 +122,14 @@ const setupWSConnection = (conn, req, docName) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
// Strip /ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
|
||||||
|
if (req.url?.startsWith('/ws_mp/')) {
|
||||||
|
req.url = req.url.slice('/ws_mp'.length)
|
||||||
|
} else if (req.url === '/ws_mp') {
|
||||||
|
req.url = '/'
|
||||||
|
}
|
||||||
console.log(`[${new Date().toISOString()}] HTTP ${req.method} ${req.url} from=${req.socket.remoteAddress}`)
|
console.log(`[${new Date().toISOString()}] HTTP ${req.method} ${req.url} from=${req.socket.remoteAddress}`)
|
||||||
if (req.url === '/' || req.url === '/health' || req.url === '/ws_mp/health') {
|
if (req.url === '/' || req.url === '/health') {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Access-Control-Allow-Origin': '*'
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
|||||||
Reference in New Issue
Block a user