457 lines
15 KiB
Python
457 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Disabled-by-default TLS edge for the read-only GPT Online Task ingress.
|
|
|
|
The internal AIOS ingress remains loopback-only. This service is a separate edge
|
|
that may bind non-loopback only when the owner explicitly approves it. It
|
|
forwards only the tiny read-only ingress API and never proxies the canonical
|
|
Bridge or Task API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict, deque
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import hmac
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any, Mapping
|
|
from urllib.parse import urlsplit
|
|
|
|
UPSTREAM = "http://127.0.0.1:18945"
|
|
MAX_BODY = 64 * 1024
|
|
MAX_RESPONSE = 256 * 1024
|
|
MAX_CONCURRENT_REQUESTS = 32
|
|
REQUEST_TIMEOUT_SECONDS = 20
|
|
RATE_WINDOW_SECONDS = 60
|
|
RATE_LIMIT_REQUESTS = 60
|
|
RATE_BURST_REQUESTS = 15
|
|
RATE_MAX_CLIENTS = 4096
|
|
_AUDIT_SALT = secrets.token_bytes(32)
|
|
PUBLIC_APPROVAL_ENV = "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED"
|
|
PUBLIC_DOMAIN_ENV = "AIOS_GPT_EDGE_PUBLIC_DOMAIN"
|
|
TASK_PATH_RE = re.compile(r"^/v1/read-only-tasks(?:/[A-Za-z0-9][A-Za-z0-9._:-]{7,127})?$")
|
|
|
|
|
|
def _client_pseudonym(value: str) -> str:
|
|
return hashlib.sha256(_AUDIT_SALT + value.encode("utf-8", "replace")).hexdigest()[:16]
|
|
|
|
|
|
def audit(event: str, **fields: object) -> None:
|
|
safe = {
|
|
"event": event,
|
|
"ts_unix_ms": int(time.time() * 1000),
|
|
}
|
|
for key, value in fields.items():
|
|
if key in {"authorization", "body", "token", "cookie", "client_ip"}:
|
|
continue
|
|
safe[key] = value
|
|
print(
|
|
json.dumps(safe, sort_keys=True, separators=(",", ":")),
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
|
|
class EdgeError(RuntimeError):
|
|
def __init__(self, code: str, status: int = 400) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
|
|
|
|
def public_bind_allowed(host: str, approval: str) -> str:
|
|
value = host.strip()
|
|
if value in {"127.0.0.1", "::1", "localhost"}:
|
|
return value
|
|
if approval != "1":
|
|
raise RuntimeError("GPT_EDGE_PUBLIC_BIND_OWNER_APPROVAL_REQUIRED")
|
|
if value not in {"0.0.0.0", "::"}:
|
|
raise RuntimeError("GPT_EDGE_BIND_ADDRESS_INVALID")
|
|
return value
|
|
|
|
|
|
def validated_public_domain(value: str) -> str:
|
|
domain = value.strip().lower()
|
|
if not domain:
|
|
return ""
|
|
if len(domain) > 253 or re.fullmatch(
|
|
r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
|
|
domain,
|
|
) is None:
|
|
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_INVALID")
|
|
return domain
|
|
|
|
|
|
def validated_edge_identity(host: str, approval: str, domain: str) -> tuple[str, str]:
|
|
bind_host = public_bind_allowed(host, approval)
|
|
public_domain = validated_public_domain(domain)
|
|
if bind_host not in {"127.0.0.1", "::1", "localhost"} and not public_domain:
|
|
raise RuntimeError("GPT_EDGE_PUBLIC_DOMAIN_REQUIRED")
|
|
return bind_host, public_domain
|
|
|
|
|
|
def validate_tls_file(path: Path, *, key: bool) -> Path:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
|
size = path.stat().st_size
|
|
if size < 16 or size > 256 * 1024:
|
|
raise RuntimeError("GPT_EDGE_TLS_FILE_INVALID")
|
|
if key and (path.stat().st_mode & 0o077):
|
|
raise RuntimeError("GPT_EDGE_TLS_KEY_MODE_INVALID")
|
|
return path
|
|
|
|
|
|
def build_tls_context(cert_path: Path, key_path: Path) -> ssl.SSLContext:
|
|
cert = validate_tls_file(cert_path, key=False)
|
|
key = validate_tls_file(key_path, key=True)
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
context.options |= ssl.OP_NO_COMPRESSION
|
|
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
|
return context
|
|
|
|
|
|
def allowed_route(method: str, raw_path: str) -> str:
|
|
parsed = urlsplit(raw_path)
|
|
if parsed.query or parsed.fragment:
|
|
raise EdgeError("QUERY_OR_FRAGMENT_DENY", 400)
|
|
path = parsed.path
|
|
if method == "GET" and path in {"/health", "/openapi.json"}:
|
|
return path
|
|
if method == "POST" and path == "/v1/read-only-tasks":
|
|
return path
|
|
if method == "GET" and TASK_PATH_RE.fullmatch(path):
|
|
return path
|
|
raise EdgeError("ROUTE_DENY", 404)
|
|
|
|
|
|
def route_label(method: str, path: str) -> str:
|
|
if method == "GET" and path == "/health":
|
|
return "health"
|
|
if method == "GET" and path == "/openapi.json":
|
|
return "openapi"
|
|
if method == "POST" and path == "/v1/read-only-tasks":
|
|
return "task_create"
|
|
if method == "GET" and path.startswith("/v1/read-only-tasks/"):
|
|
return "task_status"
|
|
return "unknown"
|
|
|
|
|
|
def forwarded_headers(headers: Mapping[str, str], *, body: bool) -> dict[str, str]:
|
|
result = {"Accept": "application/json"}
|
|
authorization = headers.get("Authorization")
|
|
if not isinstance(authorization, str) or not authorization.startswith("Bearer "):
|
|
raise EdgeError("AUTHORIZATION_REQUIRED", 401)
|
|
token = authorization[7:]
|
|
if len(token) < 32 or len(token) > 4096 or any(ch.isspace() for ch in token):
|
|
raise EdgeError("AUTHORIZATION_INVALID", 401)
|
|
result["Authorization"] = authorization
|
|
if body:
|
|
content_type = headers.get("Content-Type", "")
|
|
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
|
raise EdgeError("CONTENT_TYPE_INVALID", 415)
|
|
result["Content-Type"] = "application/json"
|
|
return result
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
window_seconds: int = RATE_WINDOW_SECONDS,
|
|
limit: int = RATE_LIMIT_REQUESTS,
|
|
burst: int = RATE_BURST_REQUESTS,
|
|
max_clients: int = RATE_MAX_CLIENTS,
|
|
now_fn=time.monotonic,
|
|
) -> None:
|
|
self.window_seconds = window_seconds
|
|
self.limit = limit
|
|
self.burst = burst
|
|
self.max_clients = max_clients
|
|
self.now_fn = now_fn
|
|
self._lock = threading.Lock()
|
|
self._events: dict[str, deque[float]] = defaultdict(deque)
|
|
|
|
def _prune_stale(self, cutoff: float) -> None:
|
|
stale = [
|
|
key
|
|
for key, events in self._events.items()
|
|
if not events or events[-1] <= cutoff
|
|
]
|
|
for key in stale:
|
|
self._events.pop(key, None)
|
|
|
|
def allowed(self, key: str) -> bool:
|
|
now = self.now_fn()
|
|
cutoff = now - self.window_seconds
|
|
with self._lock:
|
|
if key not in self._events and len(self._events) >= self.max_clients:
|
|
self._prune_stale(cutoff)
|
|
if len(self._events) >= self.max_clients:
|
|
return False
|
|
events = self._events[key]
|
|
while events and events[0] <= cutoff:
|
|
events.popleft()
|
|
if len(events) >= self.limit + self.burst:
|
|
return False
|
|
events.append(now)
|
|
return True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Proxy:
|
|
upstream: str = UPSTREAM
|
|
timeout: int = REQUEST_TIMEOUT_SECONDS
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
headers: Mapping[str, str],
|
|
body: bytes | None,
|
|
) -> tuple[int, bytes, str]:
|
|
if self.upstream != UPSTREAM:
|
|
raise EdgeError("UPSTREAM_DRIFT_DENY", 503)
|
|
req = urllib.request.Request(
|
|
self.upstream + path,
|
|
method=method,
|
|
data=body,
|
|
headers=dict(headers),
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
raw = response.read(MAX_RESPONSE + 1)
|
|
status = response.status
|
|
content_type = response.headers.get("Content-Type", "application/json")
|
|
except urllib.error.HTTPError as exc:
|
|
raw = exc.read(MAX_RESPONSE + 1)
|
|
status = exc.code
|
|
content_type = exc.headers.get("Content-Type", "application/json")
|
|
except Exception as exc:
|
|
raise EdgeError("UPSTREAM_UNAVAILABLE", 502) from exc
|
|
if len(raw) > MAX_RESPONSE:
|
|
raise EdgeError("UPSTREAM_RESPONSE_TOO_LARGE", 502)
|
|
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
|
raise EdgeError("UPSTREAM_CONTENT_TYPE_INVALID", 502)
|
|
return status, raw, "application/json"
|
|
|
|
|
|
class EdgeServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
request_queue_size = 64
|
|
allow_reuse_address = True
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
proxy = Proxy()
|
|
limiter = RateLimiter()
|
|
concurrency = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
|
expected_domain = ""
|
|
|
|
server_version = "AIOSGPTIngressEdge/1.0"
|
|
sys_version = ""
|
|
|
|
def log_message(self, *_args: Any) -> None:
|
|
return
|
|
|
|
def respond_json(self, value: Mapping[str, Any], status: int) -> None:
|
|
raw = canonical(value)
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("Content-Security-Policy", "default-src 'none'")
|
|
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def send_proxy_response(self, status: int, raw: bytes, content_type: str) -> None:
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("Content-Security-Policy", "default-src 'none'")
|
|
self.send_header("Strict-Transport-Security", "max-age=31536000")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def client_key(self) -> str:
|
|
return str(self.client_address[0])
|
|
|
|
def validate_host(self) -> None:
|
|
if not self.expected_domain:
|
|
return
|
|
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
|
if not hmac.compare_digest(host, self.expected_domain):
|
|
raise EdgeError("HOST_HEADER_DENY", 421)
|
|
|
|
def body(self) -> bytes:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "-1"))
|
|
except ValueError as exc:
|
|
raise EdgeError("CONTENT_LENGTH_INVALID") from exc
|
|
if length < 1 or length > MAX_BODY:
|
|
raise EdgeError("REQUEST_SIZE_INVALID", 413)
|
|
raw = self.rfile.read(length)
|
|
if len(raw) != length:
|
|
raise EdgeError("REQUEST_BODY_TRUNCATED", 400)
|
|
# Validate JSON at the edge so malformed bodies do not consume upstream work.
|
|
try:
|
|
value = json.loads(raw.decode("utf-8"))
|
|
except Exception as exc:
|
|
raise EdgeError("REQUEST_JSON_INVALID", 400) from exc
|
|
if not isinstance(value, Mapping):
|
|
raise EdgeError("REQUEST_JSON_OBJECT_REQUIRED", 400)
|
|
return canonical(value)
|
|
|
|
def handle_proxy(self, method: str) -> None:
|
|
client_id = _client_pseudonym(self.client_key())
|
|
started = time.monotonic()
|
|
acquired = self.concurrency.acquire(blocking=False)
|
|
if not acquired:
|
|
audit("EDGE_BUSY", client_id=client_id, method=method)
|
|
self.respond_json({"error": "EDGE_BUSY"}, 503)
|
|
return
|
|
label = "unknown"
|
|
try:
|
|
self.validate_host()
|
|
path = allowed_route(method, self.path)
|
|
label = route_label(method, path)
|
|
if path == "/health" and method == "GET":
|
|
self.respond_json(
|
|
{
|
|
"status": "ok",
|
|
"service": "aios-gpt-https-edge",
|
|
"upstream": "loopback-readonly-ingress",
|
|
},
|
|
200,
|
|
)
|
|
audit(
|
|
"EDGE_REQUEST",
|
|
client_id=client_id,
|
|
method=method,
|
|
route=label,
|
|
status=200,
|
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
)
|
|
return
|
|
if not self.limiter.allowed(self.client_key()):
|
|
audit(
|
|
"EDGE_RATE_LIMIT",
|
|
client_id=client_id,
|
|
method=method,
|
|
route=label,
|
|
)
|
|
self.respond_json({"error": "RATE_LIMITED"}, 429)
|
|
return
|
|
has_body = method == "POST"
|
|
headers = forwarded_headers(self.headers, body=has_body)
|
|
body = self.body() if has_body else None
|
|
status, raw, content_type = self.proxy.request(
|
|
method,
|
|
path,
|
|
headers,
|
|
body,
|
|
)
|
|
self.send_proxy_response(status, raw, content_type)
|
|
audit(
|
|
"EDGE_REQUEST",
|
|
client_id=client_id,
|
|
method=method,
|
|
route=label,
|
|
status=status,
|
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
)
|
|
except EdgeError as exc:
|
|
audit(
|
|
"EDGE_REJECT",
|
|
client_id=client_id,
|
|
method=method,
|
|
route=label,
|
|
status=exc.status,
|
|
error=exc.code,
|
|
)
|
|
self.respond_json({"error": exc.code}, exc.status)
|
|
finally:
|
|
self.concurrency.release()
|
|
|
|
def do_GET(self) -> None:
|
|
self.handle_proxy("GET")
|
|
|
|
def do_POST(self) -> None:
|
|
self.handle_proxy("POST")
|
|
|
|
def do_PUT(self) -> None:
|
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
|
|
|
def do_PATCH(self) -> None:
|
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
|
|
|
def do_DELETE(self) -> None:
|
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
|
|
|
def do_CONNECT(self) -> None:
|
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
|
|
|
def do_OPTIONS(self) -> None:
|
|
self.respond_json({"error": "METHOD_DENY"}, 405)
|
|
|
|
|
|
def serve() -> None:
|
|
host, public_domain = validated_edge_identity(
|
|
os.environ.get("AIOS_GPT_EDGE_HOST", "127.0.0.1"),
|
|
os.environ.get(PUBLIC_APPROVAL_ENV, "0"),
|
|
os.environ.get(PUBLIC_DOMAIN_ENV, ""),
|
|
)
|
|
port = int(os.environ.get("AIOS_GPT_EDGE_PORT", "18443"))
|
|
if not 1024 <= port <= 65535:
|
|
raise RuntimeError("GPT_EDGE_PORT_INVALID")
|
|
Handler.expected_domain = public_domain
|
|
|
|
credentials = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
|
cert_path = Path(
|
|
os.environ.get(
|
|
"AIOS_GPT_EDGE_TLS_CERT_FILE",
|
|
str(credentials / "tls_cert"),
|
|
)
|
|
)
|
|
key_path = Path(
|
|
os.environ.get(
|
|
"AIOS_GPT_EDGE_TLS_KEY_FILE",
|
|
str(credentials / "tls_key"),
|
|
)
|
|
)
|
|
context = build_tls_context(cert_path, key_path)
|
|
|
|
server = EdgeServer((host, port), Handler)
|
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
serve()
|