fix(fm): serve /internal only on its own port, never on the EWBI one

Partners reach the EWBI on 8082, and a deployment that publishes that port also published the unauthenticated /internal/* API. FM now listens on two ports (FM_PORT 8082, FM_INTERNAL_PORT 8083) and answers /internal/* on any other port with the same 404 as an unknown path.

Merge request reports

Loading
+13 −1
Changes for src/federation_manager/core/config.py: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@ import base64
import binascii
from functools import lru_cache

from pydantic import SecretStr, field_validator
from pydantic import SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


@@ -15,6 +15,12 @@ class Settings(BaseSettings):
        hide_input_in_errors=True,
    )

    # Partners reach the EWBI on `port`. `/internal/*` answers only on `internal_port`,
    # so publishing `port` to the outside never publishes the internal API with it.
    host: str = "0.0.0.0"
    port: int = 8082
    internal_port: int = 8083

    postgres_url: str = "postgresql+asyncpg://fm:fm@localhost:5433/fm_db"
    postgres_echo: bool = False
    log_level: str = "INFO"
@@ -57,6 +63,12 @@ class Settings(BaseSettings):
            _decode_key(value.get_secret_value())
        return value

    @model_validator(mode="after")
    def _internal_port_is_separate(self) -> "Settings":
        if self.internal_port == self.port:
            raise ValueError("FM_INTERNAL_PORT must differ from FM_PORT")
        return self

    @property
    def partner_secret_key_bytes(self) -> bytes | None:
        if self.partner_secret_key is None:
+28 −0
Changes for src/federation_manager/middleware/internal_port.py: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send

INTERNAL_PREFIX = "/internal"


class InternalPortOnlyMiddleware:
    """Serves `/internal/*` only to requests that arrived on the internal listener.

    Anywhere else the answer is the same 404 as for a path that doesn't exist, so the EWBI
    port doesn't even reveal that the internal API is there.
    """

    def __init__(self, app: ASGIApp, internal_port: int) -> None:
        self.app = app
        self.internal_port = internal_port

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http" and _is_internal(scope["path"]):
            server = scope.get("server")
            if server is None or server[1] != self.internal_port:
                await JSONResponse({"detail": "Not Found"}, status_code=404)(scope, receive, send)
                return
        await self.app(scope, receive, send)


def _is_internal(path: str) -> bool:
    return path == INTERNAL_PREFIX or path.startswith(INTERNAL_PREFIX + "/")
+2 −0
Changes for src/federation_manager/main.py: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -65,6 +65,7 @@ from federation_manager.core.config import Settings, get_settings
from federation_manager.core.logging import configure_logging, get_logger
from federation_manager.dependencies import get_local_operator
from federation_manager.domain.ports import EwbiClientPort
from federation_manager.middleware.internal_port import InternalPortOnlyMiddleware
from federation_manager.middleware.topology_hiding import TopologyHidingMiddleware


@@ -220,6 +221,7 @@ def create_app(lifespan: Lifespan[FastAPI] | None = None) -> FastAPI:
        lifespan=lifespan or default_lifespan,
    )
    app.add_middleware(TopologyHidingMiddleware)
    app.add_middleware(InternalPortOnlyMiddleware, internal_port=get_settings().internal_port)
    register_exception_handlers(app)
    app.openapi = _openapi_with_problem_schemas(app)  # type: ignore[method-assign]
    app.include_router(health_router)
+28 −0
Changes for src/federation_manager/server.py: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Runs FM on its two listeners: the EWBI port partners reach, and the internal one."""

import socket

import uvicorn

from federation_manager.core.config import get_settings


def _listen(host: str, port: int) -> socket.socket:
    family = socket.AF_INET6 if ":" in host else socket.AF_INET
    sock = socket.socket(family, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen(2048)
    sock.set_inheritable(True)
    return sock


def main() -> None:
    settings = get_settings()
    sockets = [_listen(settings.host, port) for port in (settings.port, settings.internal_port)]
    config = uvicorn.Config("federation_manager.main:app", log_level=settings.log_level.lower())
    uvicorn.Server(config).run(sockets=sockets)


if __name__ == "__main__":
    main()
+5 −12
Changes for tests/integration/test_fm_srm_loop.py: 5 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -211,20 +211,13 @@ def federated_stack() -> Iterator[tuple[int, UUID, UUID]]:
    port = _free_port()
    durable = f"fm-loop-{partner_id.hex[:8]}"
    process = subprocess.Popen(
        [
            sys.executable,
            "-m",
            "uvicorn",
            "federation_manager.main:app",
            "--host",
            "127.0.0.1",
            "--port",
            str(port),
            "--log-level",
            "warning",
        ],
        [sys.executable, "-m", "federation_manager.server"],
        env={
            **os.environ,
            "FM_HOST": "127.0.0.1",
            "FM_PORT": str(port),
            "FM_INTERNAL_PORT": str(_free_port()),
            "FM_LOG_LEVEL": "WARNING",
            "FM_POSTGRES_URL": FM_DB,
            "FM_KEYCLOAK_ISSUER": ISSUER,
            "FM_NATS_URL": NATS,
Loading
Loading