integration: align backend container launch and serve frontend at root

- Drop --init so the image supervisor stays namespace PID 1, as the
  runtime handoff requires; add --memory-swap=1g and nosuid,nodev tmpfs
  options to match the runtime's tested launch flags.
- Mount only frontend css/, js/ and vendor/ at the root origin so
  index.html's relative asset paths resolve; dev/tests are not exposed.
- Add X-Content-Type-Options: nosniff to every response.
- Add CONFLUENCE_WEB_CONFLUENCE_PROXY (socks5/http, Confluence only) and
  CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS; disable environment proxy
  inheritance for both upstream clients; add socksio dependency.
This commit is contained in:
Artur Mukhamadiev 2026-09-14 22:01:01 +03:00
parent a9908a533f
commit 45e6487831
8 changed files with 64 additions and 11 deletions

View File

@ -47,6 +47,8 @@ CSP_POLICY = (
"frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" "frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
) )
FRONTEND_ASSET_DIRS = ("css", "js", "vendor")
MAX_VERIFY_BODY_BYTES = 128 * 1024 MAX_VERIFY_BODY_BYTES = 128 * 1024
MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 1024 MAX_QUERY_BODY_BYTES = 6 * 16 * 1024 * 1024 + 64 * 1024
@ -207,6 +209,8 @@ class SecurityMiddleware:
headers.append((b"cache-control", b"no-store")) headers.append((b"cache-control", b"no-store"))
else: else:
headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")]) headers.extend([(b"content-security-policy", CSP_POLICY.encode()), (b"referrer-policy", b"no-referrer")])
if not any(k.lower() == b"x-content-type-options" for k, _ in headers):
headers.append((b"x-content-type-options", b"nosniff"))
message = {**message, "headers": headers} message = {**message, "headers": headers}
await send(message) await send(message)
@ -289,6 +293,7 @@ def create_app(
endpoint=app_settings.model_endpoint, endpoint=app_settings.model_endpoint,
context_window_tokens=app_settings.model_context_window_tokens, context_window_tokens=app_settings.model_context_window_tokens,
max_output_tokens=app_settings.model_max_output_tokens, max_output_tokens=app_settings.model_max_output_tokens,
timeout=app_settings.model_timeout_seconds,
) )
else: else:
raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode") raise RuntimeError(f"Unknown model_provider '{app_settings.model_provider}' in production mode")
@ -413,12 +418,16 @@ def create_app(
# Request-scoped verification client # Request-scoped verification client
client_factory = confluence_client_factory or ConfluenceClient client_factory = confluence_client_factory or ConfluenceClient
client_kwargs: Dict[str, Any] = {}
if app_settings.confluence_proxy:
client_kwargs["proxy"] = app_settings.confluence_proxy
async with client_factory( async with client_factory(
base_url=canonical_url, base_url=canonical_url,
pat=req.pat, pat=req.pat,
approved_origins=app_settings.approved_confluence_origins, approved_origins=app_settings.approved_confluence_origins,
corporate_ca_path=app_settings.corporate_ca_path, corporate_ca_path=app_settings.corporate_ca_path,
timeout=15.0, timeout=15.0,
**client_kwargs,
) as client: ) as client:
await client.verify_auth() await client.verify_auth()
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
@ -501,9 +510,14 @@ def create_app(
headers=headers, headers=headers,
) )
# Mount static frontend assets if configured # Serve only the production frontend asset directories at the root origin.
# index.html references css/, js/ and vendor/ relatively; dev/, tests/ and
# documentation inside the frontend tree are never exposed.
if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists(): if app_settings.frontend_dist_dir and app_settings.frontend_dist_dir.exists():
app.mount("/static", StaticFiles(directory=str(app_settings.frontend_dist_dir)), name="static") for asset_dir in FRONTEND_ASSET_DIRS:
directory = app_settings.frontend_dist_dir / asset_dir
if directory.is_dir():
app.mount(f"/{asset_dir}", StaticFiles(directory=str(directory)), name=f"frontend-{asset_dir}")
return app return app

View File

@ -38,6 +38,7 @@ class ConfluenceClient:
corporate_ca_path: Optional[str] = None, corporate_ca_path: Optional[str] = None,
timeout: float = 30.0, timeout: float = 30.0,
transport: Optional[httpx.AsyncBaseTransport] = None, transport: Optional[httpx.AsyncBaseTransport] = None,
proxy: Optional[str] = None,
): ):
self.canonical_url = validate_confluence_url(base_url, approved_origins) self.canonical_url = validate_confluence_url(base_url, approved_origins)
self.pat = pat.strip() self.pat = pat.strip()
@ -47,17 +48,24 @@ class ConfluenceClient:
raise InvalidInputError("Confluence PAT exceeds 8 KiB limit") raise InvalidInputError("Confluence PAT exceeds 8 KiB limit")
verify_param: Any = corporate_ca_path if corporate_ca_path else True verify_param: Any = corporate_ca_path if corporate_ca_path else True
client_kwargs: Dict[str, Any] = {}
if transport is not None:
client_kwargs["transport"] = transport
elif proxy:
# Explicit deployment proxy for Confluence only; never taken from the environment.
client_kwargs["proxy"] = proxy
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
base_url=self.canonical_url, base_url=self.canonical_url,
verify=verify_param, verify=verify_param,
follow_redirects=False, follow_redirects=False,
trust_env=False,
timeout=httpx.Timeout(timeout), timeout=httpx.Timeout(timeout),
headers={ headers={
"Authorization": f"Bearer {self.pat}", "Authorization": f"Bearer {self.pat}",
"Accept": "application/json", "Accept": "application/json",
"Accept-Encoding": "gzip, deflate", "Accept-Encoding": "gzip, deflate",
}, },
transport=transport, **client_kwargs,
) )
self._closed = False self._closed = False

View File

@ -145,7 +145,8 @@ class DockerContainerManager(ContainerManager):
"run", "run",
"--name", container_name, "--name", container_name,
"-i", # interactive attached stdin "-i", # interactive attached stdin
"--init", # No --init: the image's Python supervisor must remain namespace PID 1
# (subreaper, signal immunity, independent 180 s deadline).
"--rm", # automatically remove on exit if clean "--rm", # automatically remove on exit if clean
"--read-only", "--read-only",
"--network", "none", "--network", "none",
@ -153,10 +154,11 @@ class DockerContainerManager(ContainerManager):
"-w", "/work", "-w", "/work",
"-e", "HOME=/home/agent", "-e", "HOME=/home/agent",
"-e", "LANG=C.UTF-8", "-e", "LANG=C.UTF-8",
"--tmpfs", "/work:size=256m,uid=10001,gid=10001", "--tmpfs", "/work:rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
"--tmpfs", "/tmp:size=64m,uid=10001,gid=10001", "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
"--tmpfs", "/home/agent:size=32m,uid=10001,gid=10001", "--tmpfs", "/home/agent:rw,nosuid,nodev,size=32m,uid=10001,gid=10001",
"--memory", "1g", "--memory", "1g",
"--memory-swap", "1g", # no additional swap beyond the memory limit
"--cpus", "1.0", "--cpus", "1.0",
"--pids-limit", "128", "--pids-limit", "128",
"--cap-drop", "ALL", "--cap-drop", "ALL",

View File

@ -104,6 +104,7 @@ class OpenAIModelAdapter(ModelAdapter):
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
timeout=httpx.Timeout(timeout), timeout=httpx.Timeout(timeout),
trust_env=False,
headers={ headers={
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -150,12 +150,16 @@ class QueryRunner:
raise InvalidInputError("Prompt exceeds 16 MiB limit") raise InvalidInputError("Prompt exceeds 16 MiB limit")
# 3. Request-scoped Confluence client # 3. Request-scoped Confluence client
client_kwargs: Dict[str, Any] = {}
if self.settings.confluence_proxy:
client_kwargs["proxy"] = self.settings.confluence_proxy
confluence_client = self.confluence_client_factory( confluence_client = self.confluence_client_factory(
base_url=confluence_url, base_url=confluence_url,
pat=confluence_pat, pat=confluence_pat,
approved_origins=self.settings.approved_confluence_origins, approved_origins=self.settings.approved_confluence_origins,
corporate_ca_path=self.settings.corporate_ca_path, corporate_ca_path=self.settings.corporate_ca_path,
timeout=30.0, timeout=30.0,
**client_kwargs,
) )
confluence_dispatcher = ConfluenceDispatcher( confluence_dispatcher = ConfluenceDispatcher(
confluence_client, max_calls=self.settings.max_confluence_calls confluence_client, max_calls=self.settings.max_confluence_calls

View File

@ -21,6 +21,9 @@ class Settings(BaseModel):
default_factory=lambda: ["https://approved.example.com"] default_factory=lambda: ["https://approved.example.com"]
) )
corporate_ca_path: Optional[str] = None corporate_ca_path: Optional[str] = None
# Optional explicit outbound proxy for Confluence only (socks5://, socks5h://, http://, https://).
# Model provider traffic never uses it. Environment proxy variables are ignored.
confluence_proxy: Optional[str] = None
# Model provider configuration # Model provider configuration
model_provider: str = "fake" # Explicit fake adapter or openai; unknown values fail app creation. model_provider: str = "fake" # Explicit fake adapter or openai; unknown values fail app creation.
@ -29,6 +32,7 @@ class Settings(BaseModel):
model_endpoint: Optional[str] = None model_endpoint: Optional[str] = None
model_context_window_tokens: int = 128_000 model_context_window_tokens: int = 128_000
model_max_output_tokens: int = 4096 model_max_output_tokens: int = 4096
model_timeout_seconds: float = 60.0 # per model call HTTP timeout
# Container & Docker configuration # Container & Docker configuration
runtime_image: str = "confluence-agent:latest" runtime_image: str = "confluence-agent:latest"
@ -76,7 +80,20 @@ class Settings(BaseModel):
raise ValueError(f"Invalid approved origin: {origin}") from e raise ValueError(f"Invalid approved origin: {origin}") from e
return v return v
@field_validator("query_timeout_seconds", "cleanup_timeout_seconds") @field_validator("confluence_proxy")
@classmethod
def validate_proxy(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not v:
return None
parsed = urllib.parse.urlsplit(v)
if parsed.scheme not in ("socks5", "socks5h", "http", "https") or not parsed.hostname:
raise ValueError("confluence_proxy must be a socks5://, socks5h://, http:// or https:// URL")
return v
@field_validator("query_timeout_seconds", "cleanup_timeout_seconds", "model_timeout_seconds")
@classmethod @classmethod
def validate_deadline(cls, value): def validate_deadline(cls, value):
if not math.isfinite(value) or value <= 0: if not math.isfinite(value) or value <= 0:
@ -140,6 +157,7 @@ class Settings(BaseModel):
return cls( return cls(
approved_confluence_origins=origins, approved_confluence_origins=origins,
corporate_ca_path=os.getenv("CONFLUENCE_WEB_CORPORATE_CA_PATH"), corporate_ca_path=os.getenv("CONFLUENCE_WEB_CORPORATE_CA_PATH"),
confluence_proxy=os.getenv("CONFLUENCE_WEB_CONFLUENCE_PROXY"),
model_provider=os.getenv("CONFLUENCE_WEB_MODEL_PROVIDER", "fake"), model_provider=os.getenv("CONFLUENCE_WEB_MODEL_PROVIDER", "fake"),
model_name=os.getenv("CONFLUENCE_WEB_MODEL_NAME", "fake-model"), model_name=os.getenv("CONFLUENCE_WEB_MODEL_NAME", "fake-model"),
model_api_key=os.getenv("CONFLUENCE_WEB_MODEL_API_KEY"), model_api_key=os.getenv("CONFLUENCE_WEB_MODEL_API_KEY"),
@ -150,6 +168,7 @@ class Settings(BaseModel):
model_max_output_tokens=_parse_int( model_max_output_tokens=_parse_int(
"CONFLUENCE_WEB_MODEL_MAX_OUTPUT_TOKENS", 4096 "CONFLUENCE_WEB_MODEL_MAX_OUTPUT_TOKENS", 4096
), ),
model_timeout_seconds=_parse_float("CONFLUENCE_WEB_MODEL_TIMEOUT_SECONDS", 60.0),
runtime_image=os.getenv( runtime_image=os.getenv(
"CONFLUENCE_WEB_RUNTIME_IMAGE", "confluence-agent:latest" "CONFLUENCE_WEB_RUNTIME_IMAGE", "confluence-agent:latest"
), ),

View File

@ -8,3 +8,4 @@ httpx>=0.27.0
pydantic>=2.7.0 pydantic>=2.7.0
pytest-asyncio>=0.23.0 pytest-asyncio>=0.23.0
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
socksio>=1.0.0

View File

@ -175,11 +175,15 @@ async def test_docker_isolation_flags():
assert info["HostConfig"]["PidsLimit"] == 128 assert info["HostConfig"]["PidsLimit"] == 128
assert info["HostConfig"]["NanoCpus"] == 1_000_000_000 assert info["HostConfig"]["NanoCpus"] == 1_000_000_000
assert info["HostConfig"]["LogConfig"]["Type"] == "none" assert info["HostConfig"]["LogConfig"]["Type"] == "none"
assert info["HostConfig"]["MemorySwap"] == 1024 * 1024 * 1024 # no additional swap
assert info["HostConfig"]["Tmpfs"] == { assert info["HostConfig"]["Tmpfs"] == {
"/work": "size=256m,uid=10001,gid=10001", "/work": "rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
"/tmp": "size=64m,uid=10001,gid=10001", "/tmp": "rw,nosuid,nodev,size=64m,uid=10001,gid=10001",
"/home/agent": "size=32m,uid=10001,gid=10001", "/home/agent": "rw,nosuid,nodev,size=32m,uid=10001,gid=10001",
} }
# The image entrypoint (supervisor) must be namespace PID 1: no docker-init.
assert info["HostConfig"].get("Init") is not True
assert info["Path"] != "/sbin/docker-init"
assert info["Config"]["Labels"][settings.container_label_key + ".query_id"] == query_id assert info["Config"]["Labels"][settings.container_label_key + ".query_id"] == query_id
assert "HOME=/home/agent" in info["Config"]["Env"] assert "HOME=/home/agent" in info["Config"]["Env"]
assert not any("MODEL_API_KEY=" in v or "CONFLUENCE_PAT=" in v for v in info["Config"]["Env"]) assert not any("MODEL_API_KEY=" in v or "CONFLUENCE_PAT=" in v for v in info["Config"]["Env"])