Context
Iteration 1 currently has two parallel startup paths in Modulo:
startPlain() — a single plain-HTTP connector on port 1400 going straight to ModuloProxy. The pre-iteration-1 behavior, kept as a safety net during the front-end's first deployments.
startWithFrontend() — a fresh Jetty Server with TCP TLS + (optionally) QUIC connectors, wrapped in the full handler chain (ACME challenge → HTTPS redirect → canonical-host redirect → compression → proxy).
These two paths share no structure. They build two separate Server instances, with two separate handler trees, and there's no notion of "the chain ran" for traffic that arrived on port 1400. That made sense in iteration 1 — we needed today's behavior preserved bit-for-bit while introducing the new path — but it's not the right end state.
The insight
There's exactly one piece of logic that fundamentally matters: "decide where this request goes." Everything else (ACME challenge passthrough, HTTPS redirect, canonical-host redirect, compression, etc.) is additive policy sitting in front of that one thing.
Each of those additive handlers is either:
- Inert for traffic that doesn't need it (ACME challenge handler only fires on
/.well-known/acme-challenge/*; compression skips when client doesn't support it).
- Safe to apply behind a trusted upstream when the upstream signals via
X-Forwarded-Proto / Forwarded that the right thing already happened (HTTPS redirect, canonical-host redirect).
So the architectural endgame is one handler pipeline fed by multiple connectors, with per-connector trust hints that tell the pipeline whether to trust X-Forwarded-* (legacy-plain connector behind Apache) or its own observation (own-the-front-door TLS connector). The "frontend vs plain proxy" distinction collapses into "which connectors are configured, and what trust profile each one carries."
Target shape
Modulo
├── connectors (config-driven, any combination)
│ ├── plain HTTP on 1400 (legacy upstream-trust connector)
│ ├── TLS on 443 (own-the-front-door connector)
│ ├── QUIC on 443 (HTTP/3, same trust profile as TLS)
│ └── ...
└── handler chain (single, not duplicated)
├── ACME challenge passthrough
├── HTTPS / canonical-host / etc. policy handlers (consult connector trust)
├── compression
└── proxy (the one thing modulo fundamentally is)
This also lines up with the "modulo as a library" direction from undur/wo-adaptor-jetty#2: the reusable thing is the chain. A WO/ng app embedding the chain swaps the terminal proxy for its own handler. Same connectors, same policy handlers, different terminus.
Concrete refactor sketch
- Collapse
startPlain and startWithFrontend into one builder that:
- Builds the proxy.
- Builds the (single) handler chain wrapping it.
- Adds each configured connector to one
Server.
- Each connector carries a small trust descriptor: own-the-front-door vs behind-trusted-upstream. The policy handlers consult it to decide whether
request.isSecure() should come from the connector or from X-Forwarded-Proto.
- The legacy plain connector becomes a config option (off by default once front-end is the norm, on for transitional deployments).
Net effect should be substantially less code than today — the duplication that exists in iteration 1 disappears.
Related
Context
Iteration 1 currently has two parallel startup paths in
Modulo:startPlain()— a single plain-HTTP connector on port 1400 going straight toModuloProxy. The pre-iteration-1 behavior, kept as a safety net during the front-end's first deployments.startWithFrontend()— a fresh JettyServerwith TCP TLS + (optionally) QUIC connectors, wrapped in the full handler chain (ACME challenge → HTTPS redirect → canonical-host redirect → compression → proxy).These two paths share no structure. They build two separate
Serverinstances, with two separate handler trees, and there's no notion of "the chain ran" for traffic that arrived on port 1400. That made sense in iteration 1 — we needed today's behavior preserved bit-for-bit while introducing the new path — but it's not the right end state.The insight
There's exactly one piece of logic that fundamentally matters: "decide where this request goes." Everything else (ACME challenge passthrough, HTTPS redirect, canonical-host redirect, compression, etc.) is additive policy sitting in front of that one thing.
Each of those additive handlers is either:
/.well-known/acme-challenge/*; compression skips when client doesn't support it).X-Forwarded-Proto/Forwardedthat the right thing already happened (HTTPS redirect, canonical-host redirect).So the architectural endgame is one handler pipeline fed by multiple connectors, with per-connector trust hints that tell the pipeline whether to trust
X-Forwarded-*(legacy-plain connector behind Apache) or its own observation (own-the-front-door TLS connector). The "frontend vs plain proxy" distinction collapses into "which connectors are configured, and what trust profile each one carries."Target shape
This also lines up with the "modulo as a library" direction from undur/wo-adaptor-jetty#2: the reusable thing is the chain. A WO/ng app embedding the chain swaps the terminal proxy for its own handler. Same connectors, same policy handlers, different terminus.
Concrete refactor sketch
startPlainandstartWithFrontendinto one builder that:Server.request.isSecure()should come from the connector or fromX-Forwarded-Proto.Net effect should be substantially less code than today — the duplication that exists in iteration 1 disappears.
Related