Possible fixes needs testing

This commit is contained in:
JonatanRek 2025-04-16 18:55:11 +02:00
parent 34a19789e6
commit d1f965effe
4 changed files with 126 additions and 263 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/*

372
app.py
View File

@ -1,276 +1,148 @@
import http.server # main.py
import http.client import asyncio
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
import httpx
import yaml import yaml
import docker import docker
import threading from starlette.middleware.base import BaseHTTPMiddleware
import time from starlette.types import ASGIApp
import os from threading import Thread
import asyncio from time import time, sleep
import websockets import websockets
import hashlib from starlette.types import Receive, Scope, Send
import base64 from starlette.websockets import WebSocket
from datetime import datetime, timezone from starlette.requests import HTTPConnection
from socketserver import ThreadingMixIn
from websockets.server import WebSocketServerProtocol
# Define the target server to proxy requests to
class ProxyHandler(http.server.BaseHTTPRequestHandler):
def __init__(self, configuration, docker_client):
global activity
self.configuration = configuration
self.docker_client = docker_client
def __call__(self, *args, **kwargs): # --- Configuration Loading ---
"""Handle a request.""" class Route:
super().__init__(*args, **kwargs) def __init__(self, path_prefix: str, target: str, container: str | None = None):
self.path_prefix = path_prefix
self.target = target
self.container = container
def log_message(self, format, *args): def load_config(path: str):
pass with open(path, 'r') as f:
data = yaml.safe_load(f)
return [Route(**r) for r in data.get('routes', [])]
def finish(self,*args,**kw):
# --- Docker Management ---
idle_containers = {}
idle_timeout = {}
idle_check_interval = 5
def idle_watcher():
client = docker.from_env()
while True:
now = time()
for container, until in list(idle_containers.items()):
if until and now > until:
try: try:
if not self.wfile.closed: c = client.containers.get(container)
self.wfile.flush() c.stop()
self.wfile.close() print(f"[watcher] Stopped idle container: {container}")
except socket.error: except Exception as e:
pass print(f"[watcher] Failed to stop {container}: {e}")
self.rfile.close() finally:
idle_containers.pop(container, None)
sleep(idle_check_interval)
def do_GET(self): Thread(target=idle_watcher, daemon=True).start()
self.handle_request('GET')
def do_POST(self):
self.handle_request('POST')
def do_PUT(self): # --- Proxy Middleware ---
self.handle_request('PUT') class ReverseProxyMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp, routes: list[Route]):
super().__init__(app)
self.routes = routes
self.docker = docker.from_env()
def do_DELETE(self): async def dispatch(self, request: Request, call_next):
self.handle_request('DELETE') path = request.url.path
route = next((r for r in self.routes if path.startswith(r.path_prefix)), None)
def do_HEAD(self): if not route:
self.handle_request('HEAD') return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": "Not Found"})
def handle_request(self, method): if route.container:
#print(self.headers.get('Host').split(":")[0])
parsed_request_host = self.headers.get('Host')
if (':' in parsed_request_host):
parsed_request_host = parsed_request_host.split(":")[0]
proxy_host_configuration = next(filter(lambda host: host['domain'] == parsed_request_host, self.configuration['proxy_hosts']))
starting = False
for container in proxy_host_configuration['containers']:
container_objects = self.docker_client.containers.list(all=True, filters = { 'name' : container['container_name'] })
if (container_objects == []):
self.send_404(proxy_host_configuration['domain'])
return
container_object = container_objects[0]
if (container_object.status != 'running'):
print("starting container: {0}".format(container['container_name']))
container_object.start()
starting = True
if (starting == True):
self.send_loading(proxy_host_configuration['proxy_load_seconds'], proxy_host_configuration['domain'])
return
activity[proxy_host_configuration['domain']] = datetime.now(timezone.utc)
# Check if this is a WebSocket request
if self.headers.get("Upgrade", "").lower() == "websocket":
print("Request is WS connecting to {0}".format(container['container_name']))
print("Request is WS connecting to {0}:{1}".format(proxy_host_configuration['proxy_host'],proxy_host_configuration['proxy_port']))
activity[proxy_host_configuration['domain']] = True
self.upgrade_to_websocket(proxy_host_configuration['proxy_host'], proxy_host_configuration['proxy_port'])
return
# Open a connection to the target server
conn = http.client.HTTPConnection(proxy_host_configuration['proxy_host'], proxy_host_configuration['proxy_port'])
conn.request(method, self.path, headers=self.headers)
response = conn.getresponse()
self.send_response(response.status)
self.send_header('host', proxy_host_configuration['proxy_host'])
for header, value in response.getheaders():
self.send_header(header, value)
self.end_headers()
self.wfile.write(response.read())
conn.close()
def send_404(self, service_name):
self.send_response(404)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.end_headers()
with open(os.path.dirname(os.path.realpath(__file__)) + '/templates/404.html', 'r') as file:
html = file.read()
html = html.replace("{{SERVICE}}", service_name)
self.wfile.write(bytes(html,"utf-8"))
self.wfile.flush()
def send_loading(self, wait_time, service_name):
self.send_response(201)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('refresh', wait_time)
self.end_headers()
with open(os.path.dirname(os.path.realpath(__file__)) + '/templates/wait.html', 'r') as file:
html = file.read()
self.wfile.write(bytes(html,"utf-8"))
#self.wfile.write(bytes("starting service: {0} waiting for {1}s".format(self.headers.get('Host').split(":")[0], proxy_host_configuration['proxy_timeout_seconds']),"utf-8"))
#self.wfile.write(bytes("\nlast started at: {0} ".format(activity[proxy_host_configuration['domain']]),"utf-8"))
self.wfile.flush()
async def websocket_proxy(self, target_host, target_port):
server_ws = None
try: try:
client_connection = self.connection container = self.docker.containers.get(route.container)
if container.status != 'running':
container.start()
print(f"Started container {route.container}")
timeout = idle_timeout.get(route.container)
if timeout:
idle_containers[route.container] = time() + timeout
else:
idle_containers[route.container] = None
except Exception as e:
print(f"[proxy] Failed to ensure container '{route.container}': {e}")
return JSONResponse(status_code=status.HTTP_502_BAD_GATEWAY, content={"detail": "Container Error"})
# Establish server connection to backend # WebSocket upgrade detection
server_ws = await websockets.connect(f"ws://{target_host}:{target_port}") if request.headers.get("upgrade", "").lower() == "websocket":
print("connected") return await self.handle_websocket_upgrade(request, route)
# Bridge function to handle message forwarding
async def bridge_websockets(): new_url = route.target.rstrip('/') + path[len(route.path_prefix):]
async with httpx.AsyncClient() as client:
try:
proxied = await client.request(
method=request.method,
url=new_url,
headers=request.headers.raw,
content=await request.body(),
timeout=30.0
)
return Response(content=proxied.content, status_code=proxied.status_code, headers=proxied.headers)
except Exception as e:
print(f"[proxy] Failed proxying to {new_url}: {e}")
return JSONResponse(status_code=status.HTTP_502_BAD_GATEWAY, content={"detail": "Upstream Error"})
async def handle_websocket_upgrade(self, request: Request, route: Route):
scope: Scope = request.scope
receive: Receive = request.receive
send: Send = request._send # Unsafe, but needed for ASGI hijack
ws = WebSocket(scope, receive=receive, send=send)
await ws.accept()
target_ws_url = route.target.rstrip('/') + request.url.path[len(route.path_prefix):]
if target_ws_url.startswith("http"):
target_ws_url = target_ws_url.replace("http", "ws", 1)
try:
async with websockets.connect(target_ws_url) as backend:
async def to_backend():
try: try:
while True: while True:
# Wait for a message from the client data = await ws.receive_text()
client_message = await client_connection.recv() await backend.send(data)
print(f">: {client_message}") except Exception:
# Send it to the server await backend.close()
await server_ws.send(client_message)
# Wait for a message from the server
server_message = await server_ws.recv()
# Send it to the client
await client_connection.send(server_message)
except websockets.exceptions.ConnectionClosed as e: async def from_backend():
print(f"WebSocket connection closed: {e}") try:
except Exception as e:
print(f"Error during WebSocket communication: {e}")
# Run the bridge coroutine
await bridge_websockets()
except Exception as e:
print(f"WebSocket proxy encountered an error: {e}")
finally:
if server_ws:
await server_ws.close()
def upgrade_to_websocket(self, target_host, target_port):
"""
Handles WebSocket upgrade requests and spawns an asyncio WebSocket proxy.
"""
key = self.headers['Sec-WebSocket-Key']
accept_val = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode('utf-8')).digest()).decode('utf-8')
self.send_response(101) # Switching Protocols
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept_val)
self.end_headers()
# Upgrade the connection to a WebSocket connection
self.websocket = WebSocketServerProtocol()
self.websocket.connection_made(self.connection)
self.websocket.connection_open()
loop = asyncio.new_event_loop()
threading.Thread(target=loop.run_until_complete, args=(self.websocket_proxy(target_host, target_port),)).start()
class ThreadedHTTPServer(ThreadingMixIn, http.server.HTTPServer):
"""Handle requests in a separate thread."""
class BackgroundTasks(threading.Thread):
def __init__(self, configuration, docker_client):
super(BackgroundTasks, self).__init__()
self.configuration = configuration
self.docker_client = docker_client
def run(self,*args,**kwargs):
global activity
while True: while True:
sleep_time = 900 data = await backend.recv()
for apps in self.configuration['proxy_hosts']: await ws.send_text(data)
if(sleep_time > apps['proxy_timeout_seconds']): except Exception:
sleep_time = apps['proxy_timeout_seconds'] await ws.close()
for container in apps['containers']:
try:
container_object = self.docker_client.containers.get(container['container_name'])
if (container_object.status == 'running'):
dt = datetime.now(timezone.utc)
if (apps['domain'] in activity):
dt = activity[apps['domain']]
if (dt == True):
continue
diff_seconds = (datetime.now(timezone.utc) - dt).total_seconds()
if(diff_seconds > apps['proxy_timeout_seconds']):
print("stopping container: {0} ({1}) after {2}s".format(container['container_name'], container_object.id, diff_seconds))
container_object.stop()
except docker.errors.NotFound:
pass
time.sleep(sleep_time)
async def websocket_proxy(client_ws, target_host, target_port):
"""
Forwards WebSocket messages between the client and the target container.
"""
try:
async with websockets.connect(f"ws://{target_host}:{target_port}") as server_ws:
# Create tasks to read from both directions
async def forward_client_to_server():
async for message in client_ws:
await server_ws.send(message)
async def forward_server_to_client():
async for message in server_ws:
await client_ws.send(message)
await asyncio.gather(forward_client_to_server(), forward_server_to_client())
await asyncio.gather(to_backend(), from_backend())
except Exception as e: except Exception as e:
print(f"WebSocket error: {e}") print(f"[ws] Proxy error: {e}")
finally: await ws.close(code=1011)
await client_ws.close() return Response(status_code=502, content=b"WebSocket proxy error")
# MAIN #
if __name__ == '__main__': # --- App Setup ---
activity = {} app = FastAPI()
config = load_config("config.yml")
app.add_middleware(ReverseProxyMiddleware, routes=config)
with open('config.yml', 'r') as file:
configuration = yaml.safe_load(file)
docker_client = docker.from_env()
t = BackgroundTasks(configuration, docker_client)
t.start()
# Start the reverse proxy server on port 8888
server_address = ('', configuration['proxy_port'])
proxy_handler = ProxyHandler(configuration, docker_client)
httpd = ThreadedHTTPServer(server_address, proxy_handler)
print('Reverse proxy server running on port {0}...'.format(configuration['proxy_port']))
httpd.serve_forever()
# Optional: Health check
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@ -1,3 +1,6 @@
docker docker
PyYAML PyYAML
websockets websockets
fastapi
uvicorn[standard]
httpx

View File

@ -1,13 +0,0 @@
import asyncio
import time
import websockets
async def main():
async with websockets.connect('ws://localhost:8010') as ws:
while True:
await ws.send("testsage")
server_message = ws.recv()
print(server_message)
asyncio.get_event_loop().run_until_complete(main())