Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a51d50c7 |
@@ -0,0 +1,456 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=AIOS disabled-by-default HTTPS edge for GPT read-only Online Task ingress
|
||||
After=network-online.target aios-gpt-online-task-ingress.service
|
||||
Wants=network-online.target aios-gpt-online-task-ingress.service
|
||||
ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
LoadCredential=tls_cert:/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
LoadCredential=tls_key:/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
Environment=AIOS_GPT_EDGE_TLS_CERT_FILE=%d/tls_cert
|
||||
Environment=AIOS_GPT_EDGE_TLS_KEY_FILE=%d/tls_key
|
||||
Environment=AIOS_GPT_EDGE_HOST=127.0.0.1
|
||||
Environment=AIOS_GPT_EDGE_PORT=18443
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN=
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_gpt_https_edge.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
InaccessiblePaths=/data/aios-gitops /etc/aios-gitops /home /var/lib/aios-agent-task
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,120 @@
|
||||
# Disabled-by-default HTTPS edge for external GPT Online Task access
|
||||
|
||||
## Purpose
|
||||
|
||||
The internal ingress (`127.0.0.1:18945`) remains loopback-only. This edge is the only component
|
||||
allowed to become Internet-facing, and only after a separate owner approval.
|
||||
|
||||
It forwards only:
|
||||
|
||||
```text
|
||||
POST /v1/read-only-tasks
|
||||
GET /v1/read-only-tasks/{request_id}
|
||||
GET /openapi.json
|
||||
GET /health
|
||||
```
|
||||
|
||||
It never proxies:
|
||||
|
||||
```text
|
||||
18892 canonical Bridge
|
||||
18940 Task API
|
||||
WriterLease
|
||||
commit
|
||||
verification
|
||||
release
|
||||
tag
|
||||
baseline
|
||||
arbitrary URLs
|
||||
```
|
||||
|
||||
## Default state
|
||||
|
||||
The systemd unit is disabled by policy unless the owner creates:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge.approved
|
||||
```
|
||||
|
||||
The base unit still binds only to:
|
||||
|
||||
```text
|
||||
127.0.0.1:18443
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0
|
||||
```
|
||||
|
||||
Non-loopback bind is rejected by code unless an owner-controlled drop-in sets:
|
||||
|
||||
```text
|
||||
AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1
|
||||
AIOS_GPT_EDGE_HOST=0.0.0.0
|
||||
AIOS_GPT_EDGE_PUBLIC_DOMAIN=<approved hostname>
|
||||
```
|
||||
|
||||
This repository candidate does not create that drop-in or approval marker.
|
||||
|
||||
## TLS
|
||||
|
||||
TLS certificate/key are owner-managed systemd credentials:
|
||||
|
||||
```text
|
||||
/etc/aios-gitops/gpt-https-edge-tls.crt
|
||||
/etc/aios-gitops/gpt-https-edge-tls.key
|
||||
```
|
||||
|
||||
The service requires TLS 1.2 or newer, disables TLS compression, and never logs or returns key
|
||||
material.
|
||||
|
||||
Certificate/key issuance and renewal are external owner responsibilities until a separately
|
||||
governed ACME workflow is implemented.
|
||||
|
||||
## Request protections
|
||||
|
||||
The edge provides:
|
||||
|
||||
- 64 KiB request cap;
|
||||
- 256 KiB upstream response cap;
|
||||
- JSON validation before proxying;
|
||||
- fixed upstream `http://127.0.0.1:18945`;
|
||||
- Authorization-only header forwarding;
|
||||
- no Cookie/X-Forwarded-* trust;
|
||||
- route/method allowlist;
|
||||
- per-source-IP in-memory rate limiting;
|
||||
- bounded concurrent request handling;
|
||||
- optional exact Host-header enforcement;
|
||||
- HSTS / no-store / nosniff / no-referrer response headers.
|
||||
|
||||
The rate limiter is intentionally simple and local. Multi-node/global abuse protection is outside
|
||||
the current one-host architecture.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTPS edge does not create a new authentication scheme. It forwards the external Bearer
|
||||
credential to the loopback GPT ingress, which verifies `gpt_ingress_token`.
|
||||
|
||||
The edge never receives the AIOS control-plane token.
|
||||
|
||||
## Deployment boundary
|
||||
|
||||
Public activation is a high-risk network operation and requires explicit owner approval. Before
|
||||
activation verify:
|
||||
|
||||
```text
|
||||
host-side GPT ingress E2E=VERIFIED
|
||||
owner ingress token provisioned
|
||||
TLS certificate/key provisioned
|
||||
public hostname owned and DNS configured
|
||||
firewall/network exposure reviewed
|
||||
explicit public-bind approval recorded
|
||||
rollback plan available
|
||||
```
|
||||
|
||||
Only after those gates may an owner-authorized runtime drop-in bind this service publicly.
|
||||
|
||||
Until then:
|
||||
|
||||
```text
|
||||
GPT_HTTPS_EDGE_IMPLEMENTATION=candidate
|
||||
PUBLIC_NETWORK_EXPOSURE=NONE
|
||||
PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED
|
||||
```
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import Message
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
import aios_gpt_https_edge as edge
|
||||
|
||||
|
||||
class Headers(dict):
|
||||
def get(self,key,default=None):
|
||||
return super().get(key,default)
|
||||
|
||||
|
||||
def test_default_and_public_bind_require_explicit_owner_approval():
|
||||
assert edge.public_bind_allowed("127.0.0.1","0")=="127.0.0.1"
|
||||
assert edge.public_bind_allowed("::1","0")=="::1"
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("0.0.0.0","0")
|
||||
with pytest.raises(RuntimeError,match="OWNER_APPROVAL_REQUIRED"):
|
||||
edge.public_bind_allowed("::","")
|
||||
assert edge.public_bind_allowed("0.0.0.0","1")=="0.0.0.0"
|
||||
|
||||
|
||||
def test_arbitrary_public_bind_address_is_denied_even_with_approval():
|
||||
with pytest.raises(RuntimeError,match="BIND_ADDRESS_INVALID"):
|
||||
edge.public_bind_allowed("192.0.2.10","1")
|
||||
|
||||
|
||||
def test_public_domain_validation():
|
||||
assert edge.validated_public_domain("tasks.example.com")=="tasks.example.com"
|
||||
assert edge.validated_public_domain("")==""
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("https://example.com")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_INVALID"):
|
||||
edge.validated_public_domain("localhost")
|
||||
|
||||
|
||||
def test_route_allowlist_accepts_only_readonly_api():
|
||||
assert edge.allowed_route("POST","/v1/read-only-tasks")=="/v1/read-only-tasks"
|
||||
assert edge.allowed_route("GET","/v1/read-only-tasks/external-request-0001")==(
|
||||
"/v1/read-only-tasks/external-request-0001"
|
||||
)
|
||||
assert edge.allowed_route("GET","/health")=="/health"
|
||||
assert edge.allowed_route("GET","/openapi.json")=="/openapi.json"
|
||||
for method,path in [
|
||||
("POST","/v2/governed-task"),
|
||||
("GET","/v1/tasks/x"),
|
||||
("POST","/v1/read-only-tasks/x"),
|
||||
("DELETE","/v1/read-only-tasks/x"),
|
||||
]:
|
||||
with pytest.raises(edge.EdgeError,match="ROUTE_DENY"):
|
||||
edge.allowed_route(method,path)
|
||||
|
||||
|
||||
def test_query_and_fragment_are_denied():
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/health?x=1")
|
||||
with pytest.raises(edge.EdgeError,match="QUERY_OR_FRAGMENT_DENY"):
|
||||
edge.allowed_route("GET","/openapi.json#x")
|
||||
|
||||
|
||||
def test_forwarded_headers_are_minimal_and_require_bearer():
|
||||
incoming=Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json; charset=utf-8",
|
||||
"Cookie":"secret=1",
|
||||
"X-Forwarded-For":"203.0.113.10",
|
||||
"X-Api-Key":"do-not-forward",
|
||||
})
|
||||
forwarded=edge.forwarded_headers(incoming,body=True)
|
||||
assert forwarded=={
|
||||
"Accept":"application/json",
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"application/json",
|
||||
}
|
||||
with pytest.raises(edge.EdgeError,match="AUTHORIZATION_REQUIRED"):
|
||||
edge.forwarded_headers(Headers({}),body=False)
|
||||
|
||||
|
||||
def test_invalid_content_type_is_denied():
|
||||
with pytest.raises(edge.EdgeError,match="CONTENT_TYPE_INVALID"):
|
||||
edge.forwarded_headers(
|
||||
Headers({
|
||||
"Authorization":"Bearer "+"a"*40,
|
||||
"Content-Type":"text/plain",
|
||||
}),
|
||||
body=True,
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limiter_caps_window_and_allows_expiry():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(window_seconds=60,limit=2,burst=1,now_fn=lambda:now[0])
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert limiter.allowed("client")
|
||||
assert not limiter.allowed("client")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("client")
|
||||
|
||||
|
||||
|
||||
def test_rate_limiter_client_table_is_bounded():
|
||||
now=[100.0]
|
||||
limiter=edge.RateLimiter(
|
||||
window_seconds=60,
|
||||
limit=10,
|
||||
burst=0,
|
||||
max_clients=2,
|
||||
now_fn=lambda:now[0],
|
||||
)
|
||||
assert limiter.allowed("a")
|
||||
assert limiter.allowed("b")
|
||||
assert not limiter.allowed("c")
|
||||
now[0]=161.0
|
||||
assert limiter.allowed("c")
|
||||
assert len(limiter._events) <= 2
|
||||
|
||||
|
||||
def test_audit_source_does_not_log_sensitive_request_material():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert '"authorization", "body", "token", "cookie", "client_ip"' in source
|
||||
assert "_client_pseudonym" in source
|
||||
|
||||
def test_proxy_upstream_is_fixed():
|
||||
proxy=edge.Proxy()
|
||||
assert proxy.upstream=="http://127.0.0.1:18945"
|
||||
bad=edge.Proxy(upstream="http://127.0.0.1:18892")
|
||||
with pytest.raises(edge.EdgeError,match="UPSTREAM_DRIFT_DENY"):
|
||||
bad.request("GET","/health",{"Authorization":"Bearer "+"a"*40},None)
|
||||
|
||||
|
||||
|
||||
def test_public_bind_requires_approved_domain():
|
||||
assert edge.validated_edge_identity("127.0.0.1","0","")==("127.0.0.1","")
|
||||
with pytest.raises(RuntimeError,match="PUBLIC_DOMAIN_REQUIRED"):
|
||||
edge.validated_edge_identity("0.0.0.0","1","")
|
||||
assert edge.validated_edge_identity(
|
||||
"0.0.0.0","1","tasks.example.com"
|
||||
)==("0.0.0.0","tasks.example.com")
|
||||
|
||||
def test_tls_key_requires_no_group_or_other_permissions(tmp_path):
|
||||
key=tmp_path/"key.pem"
|
||||
key.write_text("x"*64,encoding="utf-8")
|
||||
key.chmod(0o600)
|
||||
assert edge.validate_tls_file(key,key=True)==key
|
||||
key.chmod(0o644)
|
||||
with pytest.raises(RuntimeError,match="TLS_KEY_MODE_INVALID"):
|
||||
edge.validate_tls_file(key,key=True)
|
||||
|
||||
|
||||
def test_tls_symlink_is_denied(tmp_path):
|
||||
real=tmp_path/"cert.pem"
|
||||
real.write_text("x"*64,encoding="utf-8")
|
||||
link=tmp_path/"link.pem"
|
||||
link.symlink_to(real)
|
||||
with pytest.raises(RuntimeError,match="TLS_FILE_INVALID"):
|
||||
edge.validate_tls_file(link,key=False)
|
||||
|
||||
|
||||
def test_systemd_unit_is_disabled_by_owner_approval_condition_and_loopback_default():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "ConditionPathExists=/etc/aios-gitops/gpt-https-edge.approved" in unit
|
||||
assert "AIOS_GPT_EDGE_HOST=127.0.0.1" in unit
|
||||
assert "AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=0" in unit
|
||||
assert "AIOS_GPT_EDGE_PORT=18443" in unit
|
||||
assert "LoadCredential=tls_cert:" in unit
|
||||
assert "LoadCredential=tls_key:" in unit
|
||||
assert "InaccessiblePaths=/data/aios-gitops" in unit
|
||||
assert "InaccessiblePaths=" in unit and "/var/lib/aios-agent-task" in unit
|
||||
assert "NoNewPrivileges=yes" in unit
|
||||
assert "ProtectSystem=strict" in unit
|
||||
|
||||
|
||||
def test_systemd_unit_does_not_reference_internal_control_credentials():
|
||||
unit=(ROOT/"deploy/systemd/aios-gpt-https-edge.service").read_text()
|
||||
assert "agent_task_control" not in unit
|
||||
assert "gitea" not in unit.lower()
|
||||
assert "18892" not in unit
|
||||
assert "18940" not in unit
|
||||
|
||||
|
||||
def test_edge_source_does_not_proxy_internal_bridge_or_task_api():
|
||||
source=(ROOT/"active-runtime/app/aios_gpt_https_edge.py").read_text()
|
||||
assert 'UPSTREAM = "http://127.0.0.1:18945"' in source
|
||||
assert "18892" not in source
|
||||
assert "18940" not in source
|
||||
assert "/v2/governed-task" not in source
|
||||
assert "/v1/tasks/" not in source
|
||||
|
||||
|
||||
def test_docs_keep_public_activation_separately_approved():
|
||||
doc=(ROOT/"docs/superpowers/specs/2026-08-19-gpt-https-edge.md").read_text()
|
||||
assert "explicit owner approval" in doc.lower()
|
||||
assert "PUBLIC_NETWORK_EXPOSURE=NONE" in doc
|
||||
assert "PUBLIC_CUSTOM_GPT_DIRECT_AI0S_ACCESS=NOT_VERIFIED" in doc
|
||||
Reference in New Issue
Block a user