Reverse-proxy / network-service integrations
Status: shipped pilot (issue #175)
Ships: Otto::CaddyTLS — Caddy on-demand TLS permission endpoint
Date: 2026-07
This document captures the design exploration for how Otto absorbs network-service integrations — small, optional, turnkey endpoints where an external network component (a reverse proxy, a TLS layer, a monitoring probe) speaks a fixed HTTP contract to the app, and the app supplies a tiny decision or handler while Otto owns all the HTTP ceremony.
The pilot absorbs OneTimeSecret’s “Internal ACME” app — the endpoint Caddy calls to decide whether to obtain a certificate on demand — into a reusable Otto primitive, decoupled from any application’s domain model.
Namespace note (resolved during review — see §4 and §7). This work first shipped under an umbrella
Otto::Servicesnamespace, on the assumption that CSP reporting (#174) would be its second tenant. When #174 actually landed it choseOtto::Security::CSP— it is the receiving half of Otto’s own CSP support and belongs beside the emitting half (Otto::Security::Config), not in a generic bucket. With its presumed second tenant placed elsewhere, the umbrella had exactly one occupant, so it was collapsed to a feature-namedOtto::CaddyTLS, matching the existingOtto::MCPprecedent. “Making space” turned out to mean a documented pattern plus shared primitives inOtto::Core— not a shared namespace module.
1. Problem & goal
Apps that sit behind a reverse proxy repeatedly hand-roll the same small endpoints:
- Caddy on-demand TLS asks the backend “may I get a cert for this host?”
before issuing. The backend must parse
?domain=, return200/non-2xx, restrict the caller to localhost, and fail closed on errors. - CSP violation reporting (#174) receives browser-posted violation reports, parsing two formats, bypassing CSRF, capping the body, and never raising.
Each app rebuilds this ceremony — routing, method/size limits, response semantics, security guard, fail-safe behavior — around one app-specific decision. That repetition is exactly the kind of thing Otto already removes for MCP.
Goal: make space — establish the shared pattern for these integrations and prove it with one concrete, well-decoupled pilot (Caddy on-demand TLS). Explicitly not a goal: build a framework, a registry, or the CSP endpoint now. The pattern should make CSP #174 a straightforward later sibling without being designed around it.
2. The pattern, and its two instances
| Aspect | Caddy on-demand TLS | CSP reporting (#174) |
|---|---|---|
| Direction | proxy → app (control plane) | browser → app (telemetry) |
| Verb / surface | GET ?domain= |
POST JSON body |
| App’s job | decide allow/deny for a domain | receive a normalized report |
| Otto’s job | route, guard, fail-closed, 200/403 |
intercept, CSRF-bypass, size-cap, parse, 204 |
| Caller trust | loopback only (co-located proxy) | public (any browser) |
| Coupling point | one decision block | one callback |
| Ties to an Otto subsystem | none (bridge to an external service) | yes — it’s the receiving half of Otto’s CSP header support |
| Home | Otto::CaddyTLS (top-level, like Otto::MCP) |
Otto::Security::CSP (beside CSP emission) |
The common shape is “a fixed external HTTP contract, an app-supplied
decision/handler, and Otto owning the ceremony.” That shape is the pattern worth
establishing. But the two instances differ on nearly everything else — trust
model, verb, consumer-API shape, and, decisively, their relationship to existing
Otto subsystems. Caddy TLS is a bridge to an external service (Caddy) and stands
alone; CSP reporting is one half of a capability Otto already owns. That is why
they get separate feature-named homes rather than a shared umbrella — and why the
only thing genuinely worth extracting up front is the reusable mechanism each
needs (the localhost guard here; the :outermost middleware position there),
not a namespace to file them both under.
3. Options considered
Four architectures were explored (a design panel generated and adversarially critiqued each):
- MCP-style route-based primitive.
enable_caddy_tls! { |domain| … }lazily builds a smallServerthat registers aGETroute programmatically (like/_mcp) and installs a localhost guard. Pro: mirrors the proven MCP precedent exactly; lowest review risk; turnkey. Con: one new namespace. - Callback + config, interceptor middleware (CSP #174 style). Configuration
is data (endpoint + callback); a Rack middleware intercepts the path and
short-circuits with
200/403, no route entry. Pro: symmetric with how #174 configures. Con (fatal for this endpoint): a short-circuiting interceptor that sits ahead of the guard in the stack answers before the guard runs — a fail-open exposure of the cert gate to remote clients. (This is why CSP, which is meant to be public, correctly uses the interceptor pattern, and Caddy TLS, which must be gated, correctly does not.) - Minimalist provided handler class. Otto ships a handler you reference in
routes.txtplus a guard youuseyourself. Pro: least magic. Con: the operator must remember to wire the guard separately — a “forgot the guard” footgun on a security-critical endpoint. - General network-services registry/base. A shared
Endpointbase or registry that Caddy and CSP both plug into. Con: premature abstraction — only one consumer differs (the guard), which doesn’t justify a base class (YAGNI). (#174 later confirmed this: it shared no base with Caddy TLS.)
4. Recommendation
Ship option 1 (MCP-style route-based primitive) as a top-level, feature-named
module Otto::CaddyTLS, with the localhost guard authenticating the raw TCP
peer.
Otto::CaddyTLS # opt-in integration, sibling to Otto::MCP
├── CaddyTLS::Core # mixin on Otto: enable_caddy_tls!, caddy_tls_enabled?
├── CaddyTLS::LocalhostGuard # opt-in, raw-peer loopback guard middleware
├── CaddyTLS::Server # enable!/enabled?/permit? (fail-closed), route+guard registration
└── CaddyTLS::PermissionHandler # class-method handler: ?domain= → 400/200/403
Rejected: the interceptor (fail-open risk here), routes.txt-first (guard footgun), and the registry/base (premature).
Why a feature-named module, not an Otto::Services umbrella. The umbrella was
the initial choice, justified by CSP #174 being its second tenant. That premise did
not hold (§7): #174 shipped as Otto::Security::CSP. Three signals in the codebase
all point to a feature-named home instead:
Otto::MCP— Otto’s existing external-system integration is a top-level, protocol-named namespace withenable_mcp!. Caddy TLS is the same shape.Otto::Security::CSP— even a security sub-feature is named for itself and nested under the subsystem it belongs to, not dropped in a generic bucket.- Shared mechanism lives in
Otto::Core— the one primitive CSP genuinely reused (the:outermostmiddleware position) was added tolib/otto/core/middleware_stack.rb, not to any “services” module. Otto’s instinct is: promote shared code to core, name features for themselves.
None of these support a generic Otto::Services drawer, so it was collapsed to
Otto::CaddyTLS. LocalhostGuard lives under Otto::CaddyTLS while it has a
single consumer; if a second internal-only integration ever needs it, promote it to
a shared home then, shaped by two real examples.
Public API
```ruby otto = Otto.new(‘routes.txt’)
otto.enable_caddy_tls! do |domain|
# The ONLY coupling point. Return truthy => 200 (issue cert), falsy => 403 (deny).
# domain is the only input. Any exception here is caught and denies (fail-closed).
MyApp::CustomDomain.verified?(domain)
end
otto.caddy_tls_enabled? # => true ```
Defaults: endpoint: '/_caddy/tls-permission', localhost_only: true. The route
is registered programmatically (like /_mcp) — no routes.txt entry required.
Enabling without a block raises ArgumentError (there is no allow-all default).
All setup runs through ensure_not_frozen!, so it must happen before the first
request, and re-enabling is idempotent (the route/guard are never duplicated).
Caddyfile (config-only; both directives, identical contract)
```caddyfile on_demand_tls { permission http { endpoint http://127.0.0.1:PORT/_caddy/tls-permission } }
Legacy / deprecated — same endpoint, same HTTP contract:
on_demand_tls { ask http://127.0.0.1:PORT/_caddy/tls-permission } ```
5. Security model
The endpoint gates certificate issuance, so the allow path must be
un-trickable; the deny path is naturally safe (Caddy treats any non-2xx as
deny). Everything fails closed.
5.1 Authenticate the raw peer, not the resolved client IP
This is the load-bearing decision, and it corrects the obvious-but-wrong first instinct (which every initial design in the panel made).
Otto::CaddyTLS::LocalhostGuard reads the original env['REMOTE_ADDR'] — the
TCP socket peer — and runs before IPPrivacyMiddleware rewrites REMOTE_ADDR
from forwarded headers. Because the guard is installed via Otto#use (appended,
therefore outermost in Otto’s reduce-built stack) and IPPrivacyMiddleware is
pinned innermost, the guard provably inspects the true socket peer regardless of
when enable_caddy_tls! is called.
Reading Otto’s resolved otto.client_ip (or the rewritten REMOTE_ADDR) would be
exploitable: Otto::Utils.resolve_client_ip honors X-Forwarded-For when the
peer is a trusted proxy, and a co-located Caddy on loopback is itself a natural
trusted proxy. An attacker who could route to the endpoint through it and send
X-Forwarded-For: 127.0.0.1 would be resolved to loopback and let in.
Authenticating the raw peer removes forwarded headers from the trust decision
entirely. (See spec/otto/caddy_tls/localhost_guard_spec.rb, “spoofing
resistance”.)
5.2 Reject relayed requests (forwarding headers)
A direct call is loopback peer and no forwarding headers. Caddy’s on-demand
permission request is a direct backend call and carries none; a request that was
relayed through a reverse proxy carries X-Forwarded-For (or X-Real-IP,
X-Client-IP, Forwarded). The guard denies any request to the endpoint that
carries one, even if its socket peer is loopback.
This is what makes the endpoint safe against the “accidentally bolted onto an existing app” mistake: if the endpoint is mounted inside a public app behind a proxy that connects to the backend over loopback, every proxied user request has a loopback peer — but it also carries a forwarding header, so it is denied. Only the proxy’s direct control-plane call (loopback peer, no forwarding header) is allowed.
5.3 Correct loopback detection
IPAddr.new(remote_addr).native.loopback?, wrapped rescue … => false:
.nativefolds IPv4-mapped IPv6 (::ffff:127.0.0.1, which dual-stack servers commonly present) — plain#loopback?returns false for the mapped form and would wrongly reject legitimate traffic.- Blank, malformed, or
:port-suffixed values (a non-standardREMOTE_ADDR) fail closed to401rather than raising on Caddy’s TLS-handshake hot path.
5.4 Path-scoped, bypass-resistant
The guard only enforces loopback for its own endpoint; every other route passes
through untouched. It normalizes PATH_INFO through the same
Otto::Utils.normalize_path the router uses for literal matching (URL-unescape,
scrub invalid UTF-8 bytes, strip a trailing slash), so a percent-encoded
(…permissio%6e), invalid-byte, or trailing-slash variant that the router would
still route cannot slip past by normalizing differently in the guard than at
dispatch. Sharing one implementation makes that agreement structural rather than a
duplicated invariant two files must remember to keep in sync — if the router’s
normalization ever changes, the guard changes with it.
5.5 Fail-closed everywhere
- The app block is wrapped by
Server#permit?:nil,false, or anyStandardErrordenies (403) and logs. - Blank/missing
domainreturns400before the block is consulted. - Only
?domain=reaches the decision — no other query parameter. This preserves the downstream removal ofcheck_verification(local processes must not bypass DNS verification via the query string). enable_caddy_tls!with no block raises rather than defaulting to allow-all.
5.6 Deployment: co-locate the endpoint with Caddy
The guard is loopback-only by design, and it stays that way even when Caddy
and the application run on different hosts. The recommended topology is to run
the permission endpoint as a tiny Otto app on the same host as Caddy — see
examples/caddy_tls_demo/standalone.ru:
[ Caddy host ] [ App / data host(s) ]
Caddy ──loopback──▶ tiny Otto app ──(your own authenticated channel)──▶ data
(127.0.0.1) enable_caddy_tls! { |domain| ... }
The Caddy → endpoint hop is always loopback (secure, unspoofable). The endpoint’s decision block is app-supplied, so it reaches the real domain data over whatever channel the app already trusts (an internal API call, a shared database, a cache). This keeps the authentication boundary simple and strong (loopback) while the data lookup crosses hosts however the app likes.
Rejected alternative: widening the guard to a configurable trusted-source IP allowlist so Caddy could call cross-host directly. It trades an unspoofable boundary (loopback) for a spoofable one (source IP on a shared network) and adds configuration surface, for no capability the co-location topology doesn’t already provide. Loopback-only + co-location is the better overall design.
Additional layers (defense in depth):
- Dedicated loopback port. Bind the endpoint app on
127.0.0.1:PORTserving only the permission route, so it is unreachable from off-host by construction. -
Proxy path block. If you do mount the endpoint inside a proxied app, also block the path at the proxy. Caddy’s
on_demand_tlscall bypasses Caddy’s own route rules, so blocking the public path does not affect certificate validation:caddyfile @tls_permission path /_caddy/tls-permission respond @tls_permission 404
6. Absorbing the OneTimeSecret “Internal ACME” app
The behavior maps 1:1, decoupled and hardened:
| OneTimeSecret | Otto pilot |
|---|---|
AskHandler.call (?domain= → 400/200/403, text/plain OK/Forbidden) |
CaddyTLS::PermissionHandler.handle (identical) |
LocalhostOnly (trusts resolved REMOTE_ADDR, plain #loopback?) |
CaddyTLS::LocalhostGuard (raw peer + reject forwarding headers, .native.loopback?, fail-closed, router-equivalent path scoping) |
Application.domain_allowed? → CustomDomain…ready? (the coupling) |
app-supplied enable_caddy_tls! block |
domain_allowed? rescue => false |
absorbed into Server#permit? so every consumer inherits it |
check_verification removed from HTTP surface |
preserved: only ?domain= is read |
7. How CSP reporting (#174) actually landed — and what it taught us
This is the part that validated (and corrected) the design. CSP reporting shipped
not as a second tenant of this namespace but as Otto::Security::CSP
(Parser, Report, ReportMiddleware), enabled via
enable_csp_reporting!(report_uri) { |report| … } on Otto::Security::Core. It is
a public, unauthenticated Rack middleware pinned :outermost, always answers
204, size-caps the body, and dispatches each parsed report through a
fire-and-forget callback held on Otto::Security::Config.
What that outcome confirms:
- The pattern is real. CSP is the same shape — a fixed external HTTP contract, an app-supplied handler, Otto owning the ceremony — so the pilot did generalize a genuine recurring need.
- The shared-namespace hypothesis was wrong. CSP belongs beside Otto’s CSP
emission (
report-uridirective, nonce policy) inOtto::Security; it sharescsp_report_uriand the violation callback with header generation. Filing it in a genericOtto::Serviceswould have severed it from the code it is one half of. A network-service integration goes wherever its domain is —Otto::Securityfor CSP, its own module for the Caddy bridge — not into a bucket named after the mechanism. - What’s actually shared is mechanism, and it lives in core. The one thing both
features needed was a way to run a middleware ahead of CSRF/auth. That became the
generic
:outermostposition inOtto::Core’s middleware stack — reusable by any integration, owned by none. - The guard is correctly not shared. CSP reports come from browsers, so CSP
opts out of any localhost guard and instead leans on content-type gating, a size
cap, and Otto’s rate limiting. That opt-out is exactly why
LocalhostGuardis a standalone building block rather than baked into a base class — and it is the post-hoc comparison #174 asked for: a modular CSP endpoint differs from a hand-built one only in that the enable/callback/route plumbing is conventionalized.
8. Decisions
Resolved during review:
- Namespace:
Otto::CaddyTLS(top-level, feature-named, mirroringOtto::MCP). The earlierOtto::Servicesumbrella was collapsed once #174 choseOtto::Security::CSP, leaving the umbrella with a single tenant (§4, §7). - Loopback-only, even cross-host. The endpoint stays loopback-only; when Caddy and the app run on different hosts, co-locate the tiny permission app with Caddy (§5.6). Chosen over a configurable trusted-source IP allowlist because loopback is an unspoofable boundary and co-location needs no extra config or trust.
- API shape:
enable_caddy_tls!(code-side) is the primary, secure-by-default path — it bundles route + guard + decision so the endpoint cannot exist without its guard. The handler class is resolvable from a routes file for advanced users, but that split (route declared without guard) is the exact footgun we avoid, so it is not the documented path. - Multi-instance: the handler resolves its
Serverper-request from the Otto instance the dispatcher binds to it (not a class-level global), so multiple Otto apps in one process each evaluate their own permission block.
Still the maintainer’s call:
- Default endpoint path:
'/_caddy/tls-permission'(_-prefixed like/_mcp). The absorbed app used/api/internal/acme/ask. - Guard denial status:
401(parity with the absorbedLocalhostOnly) vs403. Both are non-2xx, so Caddy denies either way.
References
- Caddy on-demand TLS / permission module — https://caddyserver.com/docs/json/apps/tls/automation/on_demand/permission/http/
- Issue #175 (this work) · Issue #174 (CSP reporting, shipped as
Otto::Security::CSP) - Precedent:
lib/otto/mcp/(modular protocol integration) - Code:
lib/otto/caddy_tls/· Specs:spec/otto/caddy_tls/· Example:examples/caddy_tls_demo/