Aggiunto un log migliore
This commit is contained in:
@@ -13,7 +13,6 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, Optional
|
from typing import Any, Dict, Iterable, Optional
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
|
||||||
@@ -28,8 +27,6 @@ STARTUP_STABILIZATION_SECONDS = float(os.getenv("STARTUP_STABILIZATION_SECONDS",
|
|||||||
TCP_CONNECT_TIMEOUT_SECONDS = float(os.getenv("TCP_CONNECT_TIMEOUT_SECONDS", "1.5"))
|
TCP_CONNECT_TIMEOUT_SECONDS = float(os.getenv("TCP_CONNECT_TIMEOUT_SECONDS", "1.5"))
|
||||||
INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3"))
|
INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3"))
|
||||||
MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536"))
|
MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536"))
|
||||||
GATEWAY_ROUTE_TTL_SECONDS = int(os.getenv("GATEWAY_ROUTE_TTL_SECONDS", "3600"))
|
|
||||||
GATEWAY_ROUTE_COOKIE_NAME = "fis_user_route"
|
|
||||||
|
|
||||||
DEFAULTS: Dict[str, Any] = {
|
DEFAULTS: Dict[str, Any] = {
|
||||||
"container_prefix": "fis-",
|
"container_prefix": "fis-",
|
||||||
@@ -46,7 +43,6 @@ DEFAULTS: Dict[str, Any] = {
|
|||||||
_docker_client = None
|
_docker_client = None
|
||||||
user_activity: Dict[str, float] = {}
|
user_activity: Dict[str, float] = {}
|
||||||
users_by_id: Dict[str, Dict[str, Any]] = {}
|
users_by_id: Dict[str, Dict[str, Any]] = {}
|
||||||
gateway_client_routes: Dict[str, Dict[str, Any]] = {}
|
|
||||||
user_container_locks: Dict[str, threading.Lock] = {}
|
user_container_locks: Dict[str, threading.Lock] = {}
|
||||||
user_container_locks_guard = threading.Lock()
|
user_container_locks_guard = threading.Lock()
|
||||||
|
|
||||||
@@ -222,45 +218,53 @@ def build_http_error(status_code: int, reason: str, message: str) -> bytes:
|
|||||||
return "\r\n".join(headers).encode("utf-8") + body
|
return "\r\n".join(headers).encode("utf-8") + body
|
||||||
|
|
||||||
|
|
||||||
def build_http_redirect(location: str, extra_headers: Optional[Iterable[str]] = None) -> bytes:
|
def build_http_redirect(location: str) -> bytes:
|
||||||
headers = [
|
headers = [
|
||||||
"HTTP/1.1 302 Found",
|
"HTTP/1.1 302 Found",
|
||||||
f"Location: {location}",
|
f"Location: {location}",
|
||||||
"Content-Length: 0",
|
"Content-Length: 0",
|
||||||
"Connection: close",
|
"Connection: close",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
headers.extend(extra_headers or [])
|
|
||||||
headers.extend(["", ""])
|
|
||||||
return "\r\n".join(headers).encode("utf-8")
|
return "\r\n".join(headers).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def rewrite_http_request_target(data: bytes, target: str) -> bytes:
|
def build_user_host(host: str, user_id: str) -> Optional[str]:
|
||||||
header_end = data.find(b"\r\n")
|
if not host:
|
||||||
if header_end == -1:
|
|
||||||
return data
|
|
||||||
|
|
||||||
request_line = data[:header_end].decode("iso-8859-1", errors="replace")
|
|
||||||
parts = request_line.split(" ", 2)
|
|
||||||
if len(parts) != 3:
|
|
||||||
return data
|
|
||||||
|
|
||||||
method, _, version = parts
|
|
||||||
rewritten_line = f"{method} {target} {version}".encode("iso-8859-1")
|
|
||||||
return rewritten_line + data[header_end:]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_http_request(data: bytes):
|
|
||||||
if not data:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
hostname, separator, port = host.partition(":")
|
||||||
|
hostname = hostname.strip().lower()
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if hostname == "localhost":
|
||||||
|
target_host = f"{user_id}.localhost"
|
||||||
|
else:
|
||||||
|
labels = [label for label in hostname.split(".") if label]
|
||||||
|
if not labels:
|
||||||
|
return None
|
||||||
|
if labels[0] == user_id:
|
||||||
|
target_host = hostname
|
||||||
|
else:
|
||||||
|
target_host = ".".join([user_id, *labels])
|
||||||
|
|
||||||
|
return f"{target_host}:{port}" if separator else target_host
|
||||||
|
|
||||||
|
|
||||||
|
def extract_user_from_http_request(data: bytes, known_users: Dict[str, Dict[str, Any]]):
|
||||||
|
if not data:
|
||||||
|
return None, data, None
|
||||||
|
|
||||||
header_end = data.find(b"\r\n\r\n")
|
header_end = data.find(b"\r\n\r\n")
|
||||||
if header_end == -1:
|
if header_end == -1:
|
||||||
return None
|
return None, data, None
|
||||||
|
|
||||||
header_block = data[:header_end].decode("iso-8859-1", errors="replace")
|
header_block = data[:header_end].decode("iso-8859-1", errors="replace")
|
||||||
lines = header_block.split("\r\n")
|
lines = header_block.split("\r\n")
|
||||||
if not lines or len(lines[0].split(" ")) < 3:
|
if not lines or len(lines[0].split(" ")) < 3:
|
||||||
return None
|
return None, data, None
|
||||||
|
|
||||||
method, target, version = lines[0].split(" ", 2)
|
method, target, version = lines[0].split(" ", 2)
|
||||||
headers = {}
|
headers = {}
|
||||||
@@ -270,98 +274,29 @@ def parse_http_request(data: bytes):
|
|||||||
key, value = line.split(":", 1)
|
key, value = line.split(":", 1)
|
||||||
headers[key.strip().lower()] = value.strip()
|
headers[key.strip().lower()] = value.strip()
|
||||||
|
|
||||||
return {
|
|
||||||
"method": method,
|
|
||||||
"target": target,
|
|
||||||
"version": version,
|
|
||||||
"headers": headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_cookie_user(headers: Dict[str, str], known_users: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
||||||
cookie_header = headers.get("cookie", "")
|
|
||||||
if not cookie_header:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for part in cookie_header.split(";"):
|
|
||||||
key, separator, value = part.strip().partition("=")
|
|
||||||
if separator and key == GATEWAY_ROUTE_COOKIE_NAME:
|
|
||||||
user_id = value.strip()
|
|
||||||
return known_users.get(user_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_websocket_request(headers: Dict[str, str]) -> bool:
|
|
||||||
return headers.get("upgrade", "").lower() == "websocket"
|
|
||||||
|
|
||||||
|
|
||||||
def extract_request_target(data: bytes) -> Optional[str]:
|
|
||||||
parsed = parse_http_request(data)
|
|
||||||
if not parsed:
|
|
||||||
return None
|
|
||||||
return parsed["target"]
|
|
||||||
|
|
||||||
|
|
||||||
def remember_gateway_route(client_ip: str, user_id: str) -> None:
|
|
||||||
if not client_ip:
|
|
||||||
return
|
|
||||||
gateway_client_routes[client_ip] = {"user_id": user_id, "updated_at": time.time()}
|
|
||||||
|
|
||||||
|
|
||||||
def get_remembered_gateway_user(client_ip: str) -> Optional[Dict[str, Any]]:
|
|
||||||
if not client_ip:
|
|
||||||
return None
|
|
||||||
|
|
||||||
route = gateway_client_routes.get(client_ip)
|
|
||||||
if not route:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if time.time() - route["updated_at"] > GATEWAY_ROUTE_TTL_SECONDS:
|
|
||||||
gateway_client_routes.pop(client_ip, None)
|
|
||||||
return None
|
|
||||||
|
|
||||||
user_id = route["user_id"]
|
|
||||||
return users_by_id.get(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_user_from_http_request(data: bytes, known_users: Dict[str, Dict[str, Any]]):
|
|
||||||
parsed = parse_http_request(data)
|
|
||||||
if not parsed:
|
|
||||||
return None, data, None, False
|
|
||||||
|
|
||||||
target = parsed["target"]
|
|
||||||
headers = parsed["headers"]
|
|
||||||
|
|
||||||
host = headers.get("host", "").split(":", 1)[0].strip().lower()
|
host = headers.get("host", "").split(":", 1)[0].strip().lower()
|
||||||
if host:
|
if host:
|
||||||
labels = [label for label in host.split(".") if label]
|
labels = [label for label in host.split(".") if label]
|
||||||
if labels:
|
if labels:
|
||||||
candidate = labels[0]
|
candidate = labels[0]
|
||||||
if candidate in known_users and candidate not in {"localhost", "127", "0"}:
|
if candidate in known_users and candidate not in {"localhost", "127", "0"}:
|
||||||
return known_users[candidate], data, None, False
|
return known_users[candidate], data, None
|
||||||
|
|
||||||
parsed_target = urlsplit(target)
|
path, separator, query = target.partition("?")
|
||||||
if parsed_target.scheme and parsed_target.netloc:
|
|
||||||
path = parsed_target.path or "/"
|
|
||||||
query = parsed_target.query
|
|
||||||
separator = "?" if query else ""
|
|
||||||
else:
|
|
||||||
path, separator, query = target.partition("?")
|
|
||||||
|
|
||||||
segments = [segment for segment in path.split("/") if segment]
|
segments = [segment for segment in path.split("/") if segment]
|
||||||
if segments:
|
if segments:
|
||||||
candidate = segments[0]
|
candidate = segments[0]
|
||||||
if candidate in known_users:
|
if candidate in known_users:
|
||||||
rewritten_path = "/" + "/".join(segments[1:]) if len(segments) > 1 else "/"
|
user_host = build_user_host(headers.get("host", ""), candidate)
|
||||||
rewritten_target = f"{rewritten_path}?{query}" if separator else rewritten_path
|
if user_host:
|
||||||
rewritten_data = rewrite_http_request_target(data, rewritten_target)
|
redirect_path = "/" + "/".join(segments[1:]) if len(segments) > 1 else "/"
|
||||||
return known_users[candidate], rewritten_data, None, True
|
if separator:
|
||||||
|
redirect_path = f"{redirect_path}?{query}"
|
||||||
|
redirect_target = f"http://{user_host}{redirect_path}"
|
||||||
|
return None, data, redirect_target
|
||||||
|
|
||||||
cookie_user = extract_cookie_user(headers, known_users)
|
return None, data, None
|
||||||
if cookie_user:
|
|
||||||
return cookie_user, data, None, False
|
|
||||||
|
|
||||||
return None, data, None, False
|
|
||||||
|
|
||||||
|
|
||||||
def wait_until_ready(container_name: str) -> bool:
|
def wait_until_ready(container_name: str) -> bool:
|
||||||
@@ -535,7 +470,6 @@ async def handle_connection(reader, writer, user: Dict[str, Any], initial_data:
|
|||||||
|
|
||||||
async def handle_gateway_connection(reader, writer):
|
async def handle_gateway_connection(reader, writer):
|
||||||
peer = writer.get_extra_info("peername")
|
peer = writer.get_extra_info("peername")
|
||||||
client_ip = peer[0] if isinstance(peer, tuple) and peer else None
|
|
||||||
try:
|
try:
|
||||||
initial_data = await read_initial_http_request(reader)
|
initial_data = await read_initial_http_request(reader)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
@@ -545,11 +479,7 @@ async def handle_gateway_connection(reader, writer):
|
|||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed_request = parse_http_request(initial_data)
|
user, rewritten_data, redirect_target = extract_user_from_http_request(initial_data, users_by_id)
|
||||||
user, rewritten_data, redirect_target, selected_via_path = extract_user_from_http_request(initial_data, users_by_id)
|
|
||||||
if user:
|
|
||||||
remember_gateway_route(client_ip, user["id"])
|
|
||||||
|
|
||||||
if redirect_target:
|
if redirect_target:
|
||||||
writer.write(build_http_redirect(redirect_target))
|
writer.write(build_http_redirect(redirect_target))
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
@@ -557,29 +487,6 @@ async def handle_gateway_connection(reader, writer):
|
|||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
return
|
return
|
||||||
|
|
||||||
if user and selected_via_path and parsed_request and not is_websocket_request(parsed_request["headers"]):
|
|
||||||
normalized_target = extract_request_target(rewritten_data) or "/"
|
|
||||||
writer.write(
|
|
||||||
build_http_redirect(
|
|
||||||
normalized_target,
|
|
||||||
extra_headers=[
|
|
||||||
f"Set-Cookie: {GATEWAY_ROUTE_COOKIE_NAME}={user['id']}; Path=/; HttpOnly; SameSite=Lax"
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await writer.drain()
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
log(f"[gateway] Switched {client_ip} to user {user['id']} via path route")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
remembered_user = get_remembered_gateway_user(client_ip)
|
|
||||||
if remembered_user:
|
|
||||||
user = remembered_user
|
|
||||||
rewritten_data = initial_data
|
|
||||||
log(f"[gateway] Reused remembered route for {client_ip} -> user {user['id']}")
|
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
writer.write(
|
writer.write(
|
||||||
build_http_error(
|
build_http_error(
|
||||||
@@ -603,10 +510,6 @@ async def idle_checker():
|
|||||||
await asyncio.sleep(IDLE_CHECK_INTERVAL)
|
await asyncio.sleep(IDLE_CHECK_INTERVAL)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
for client_ip, route in list(gateway_client_routes.items()):
|
|
||||||
if now - route["updated_at"] > GATEWAY_ROUTE_TTL_SECONDS:
|
|
||||||
gateway_client_routes.pop(client_ip, None)
|
|
||||||
|
|
||||||
for user_id, last_active in list(user_activity.items()):
|
for user_id, last_active in list(user_activity.items()):
|
||||||
if now - last_active <= INACTIVITY_SECONDS:
|
if now - last_active <= INACTIVITY_SECONDS:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -192,14 +192,13 @@ class ManagerConfigTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
users = {"user2": {"id": "user2"}}
|
users = {"user2": {"id": "user2"}}
|
||||||
|
|
||||||
user, rewritten, redirect_target, selected_via_path = self.manager.extract_user_from_http_request(request, users)
|
user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user2")
|
self.assertEqual(user["id"], "user2")
|
||||||
self.assertEqual(rewritten, request)
|
self.assertEqual(rewritten, request)
|
||||||
self.assertIsNone(redirect_target)
|
self.assertIsNone(redirect_target)
|
||||||
self.assertFalse(selected_via_path)
|
|
||||||
|
|
||||||
def test_extract_user_from_path_rewrites_request_for_direct_proxying(self):
|
def test_extract_user_from_path_returns_redirect_target(self):
|
||||||
request = (
|
request = (
|
||||||
b"GET /user1/websockify?token=abc HTTP/1.1\r\n"
|
b"GET /user1/websockify?token=abc HTTP/1.1\r\n"
|
||||||
b"Host: localhost:8080\r\n"
|
b"Host: localhost:8080\r\n"
|
||||||
@@ -208,112 +207,11 @@ class ManagerConfigTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
users = {"user1": {"id": "user1"}}
|
users = {"user1": {"id": "user1"}}
|
||||||
|
|
||||||
user, rewritten, redirect_target, selected_via_path = self.manager.extract_user_from_http_request(request, users)
|
user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user1")
|
|
||||||
self.assertEqual(
|
|
||||||
rewritten,
|
|
||||||
(
|
|
||||||
b"GET /websockify?token=abc HTTP/1.1\r\n"
|
|
||||||
b"Host: localhost:8080\r\n"
|
|
||||||
b"Upgrade: websocket\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertIsNone(redirect_target)
|
|
||||||
self.assertTrue(selected_via_path)
|
|
||||||
|
|
||||||
def test_extract_user_from_bare_user_path_rewrites_to_root(self):
|
|
||||||
request = (
|
|
||||||
b"GET /user1 HTTP/1.1\r\n"
|
|
||||||
b"Host: 10.200.200.113:8080\r\n"
|
|
||||||
b"Connection: keep-alive\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
)
|
|
||||||
users = {"user1": {"id": "user1"}}
|
|
||||||
|
|
||||||
user, rewritten, redirect_target, selected_via_path = self.manager.extract_user_from_http_request(request, users)
|
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user1")
|
|
||||||
self.assertEqual(
|
|
||||||
rewritten,
|
|
||||||
(
|
|
||||||
b"GET / HTTP/1.1\r\n"
|
|
||||||
b"Host: 10.200.200.113:8080\r\n"
|
|
||||||
b"Connection: keep-alive\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertIsNone(redirect_target)
|
|
||||||
self.assertTrue(selected_via_path)
|
|
||||||
|
|
||||||
def test_extract_user_from_absolute_form_request_rewrites_to_root(self):
|
|
||||||
request = (
|
|
||||||
b"GET http://10.200.200.113:8080/user1 HTTP/1.1\r\n"
|
|
||||||
b"Host: 10.200.200.113:8080\r\n"
|
|
||||||
b"Connection: keep-alive\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
)
|
|
||||||
users = {"user1": {"id": "user1"}}
|
|
||||||
|
|
||||||
user, rewritten, redirect_target, selected_via_path = self.manager.extract_user_from_http_request(request, users)
|
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user1")
|
|
||||||
self.assertEqual(
|
|
||||||
rewritten,
|
|
||||||
(
|
|
||||||
b"GET / HTTP/1.1\r\n"
|
|
||||||
b"Host: 10.200.200.113:8080\r\n"
|
|
||||||
b"Connection: keep-alive\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertIsNone(redirect_target)
|
|
||||||
self.assertTrue(selected_via_path)
|
|
||||||
|
|
||||||
def test_extract_user_from_cookie_route(self):
|
|
||||||
request = (
|
|
||||||
b"GET / HTTP/1.1\r\n"
|
|
||||||
b"Host: 10.200.200.113:8080\r\n"
|
|
||||||
b"Cookie: fis_user_route=user2\r\n"
|
|
||||||
b"Connection: keep-alive\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
)
|
|
||||||
users = {"user2": {"id": "user2"}}
|
|
||||||
|
|
||||||
user, rewritten, redirect_target, selected_via_path = self.manager.extract_user_from_http_request(request, users)
|
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user2")
|
|
||||||
self.assertEqual(rewritten, request)
|
|
||||||
self.assertIsNone(redirect_target)
|
|
||||||
self.assertFalse(selected_via_path)
|
|
||||||
|
|
||||||
def test_remembered_gateway_route_returns_user_within_ttl(self):
|
|
||||||
self.manager.users_by_id = {"user1": {"id": "user1"}}
|
|
||||||
self.manager.gateway_client_routes.clear()
|
|
||||||
self.manager.time = types.SimpleNamespace(time=lambda: 100.0)
|
|
||||||
self.manager.GATEWAY_ROUTE_TTL_SECONDS = 60
|
|
||||||
|
|
||||||
self.manager.remember_gateway_route("10.200.200.136", "user1")
|
|
||||||
|
|
||||||
user = self.manager.get_remembered_gateway_user("10.200.200.136")
|
|
||||||
|
|
||||||
self.assertEqual(user["id"], "user1")
|
|
||||||
|
|
||||||
def test_remembered_gateway_route_expires_after_ttl(self):
|
|
||||||
now = {"value": 100.0}
|
|
||||||
self.manager.users_by_id = {"user1": {"id": "user1"}}
|
|
||||||
self.manager.gateway_client_routes.clear()
|
|
||||||
self.manager.time = types.SimpleNamespace(time=lambda: now["value"])
|
|
||||||
self.manager.GATEWAY_ROUTE_TTL_SECONDS = 60
|
|
||||||
|
|
||||||
self.manager.remember_gateway_route("10.200.200.136", "user1")
|
|
||||||
now["value"] = 161.0
|
|
||||||
|
|
||||||
user = self.manager.get_remembered_gateway_user("10.200.200.136")
|
|
||||||
|
|
||||||
self.assertIsNone(user)
|
self.assertIsNone(user)
|
||||||
self.assertEqual(self.manager.gateway_client_routes, {})
|
self.assertEqual(rewritten, request)
|
||||||
|
self.assertEqual(redirect_target, "http://user1.localhost:8080/websockify?token=abc")
|
||||||
|
|
||||||
def test_wait_until_ready_applies_stabilization_delay(self):
|
def test_wait_until_ready_applies_stabilization_delay(self):
|
||||||
sleep_calls = []
|
sleep_calls = []
|
||||||
|
|||||||
Reference in New Issue
Block a user