Deployment¶
One image runs two kinds of process: the HTTP server, FrankenPHP in worker mode inside the Caddy server it is built on, and the worker, which consumes the lanes of Messenger and the schedule of the timed steps. Beside them the provider needs PostgreSQL, RabbitMQ and a key management service.
The image¶
apps/op/Dockerfile builds on dunglas/frankenphp:1-php8.5 and adds the extensions the provider needs:
apcu, intl, opcache, zip, pdo_pgsql, amqp, gmp, sodium and uuid. Its build context is the
root of the monorepo, because the application installs the packages beside it through path repositories.
| Stage | What it is |
|---|---|
frankenphp_base |
The extensions, the PHP settings of the application, the entrypoint and the Caddy configuration |
frankenphp_dev |
The development image, into which the stack mounts the monorepo, running FrankenPHP with --watch |
frankenphp_prod |
The production image: the packages are copied to /src and the application to /app, installed without the development dependencies, with an authoritative class map, .env and any .env.local beside it compiled for production, the stylesheet built and the assets compiled |
apps/op/ is copied as the working copy holds it, and composer dump-env prod compiles a .env.local it
finds there. The one the development stack writes holds its key management service, the secrets of its clients
and the password of its user, so the image is built from a checkout that holds no .env.local.
The production image sets APP_ENV=prod, reads php.ini-production, preloads config/preload.php into
OPcache, and turns worker mode on: the kernel is booted once per worker and serves every request that worker
takes, so a signing key opened with the key management service stays open in it, and the service is asked once
per key and per worker rather than once per signature.
The HTTP server¶
The command of the image is frankenphp run --config /etc/frankenphp/Caddyfile. Before it starts, the
entrypoint does two things when .env names a DATABASE_URL, which the one of the application does:
- it waits for the database, sixty attempts one second apart, and stops at once on an error it cannot recover from;
- it plays the migrations,
doctrine:migrations:migrate --no-interaction --all-or-nothing, every time the server starts. The migrations of the bundle are written for PostgreSQL and refuse any other platform.
The health check of the image asks the metrics endpoint of the Caddy admin API inside the container,
curl -f http://localhost:2019/metrics, after a start period of sixty seconds.
The two planes¶
Every route of the bundle carries its plane in its name, beffroi_protocol_* for what a client or a resource
server calls and beffroi_interaction_* for what a browser reads. BEFFROI_PLANE restricts a process to one
of them:
BEFFROI_PLANE |
What the process serves |
|---|---|
all |
Both planes, which is what .env says |
protocol |
The protocol plane; every route of the interaction plane answers 404 |
interaction |
The interaction plane; every route of the protocol plane answers 404 |
The refusal comes after the router and before the firewall, so a request to the other plane opens no session
and counts against no budget. The root / of the application belongs to the interaction plane, and a route
whose name carries no plane, such as /theme, is served by every process. A process serves its plane on each
of its hosts, the one that asks for a client certificate included. Whatever sends a request to one process or
the other reads its path, and the endpoints give the plane of every path.
The worker¶
The worker is the same image, running the consumer of Messenger on the schedule and the four lanes of the provider, in this order, which is the order it takes them in:
bin/console messenger:consume scheduler_beffroi async_notifications async_priority_high async_priority_medium async_priority_low --time-limit=3600 --memory-limit=256M
Started with bin/console, the entrypoint waits for the database as it does for the server, and plays no
migration: the server is the process that migrates, and the development stack starts the worker once the server
reports healthy. The consumer stops after an hour, or once it holds 256 MB, and the restart policy of the stack
starts it again.
| Transport | What it carries |
|---|---|
scheduler_beffroi |
The clock of the timed steps. One process consumes it: the steps are idempotent, and are not written to run twice at once |
async_notifications |
What the provider carries to somebody it does not own: logout tokens (Back-Channel Logout 1.0 §2.5), the notification that a backchannel request was answered (CIBA Core §10.2), and web push to the browsers of a person (RFC 8030) |
async_priority_high |
Nothing the bundle routes |
async_priority_medium |
The writes a request does not wait for, and the timed steps the schedule hands over |
async_priority_low |
The events of the core and of the protocol, which the journal writes down and the enrichment of a provenance reads |
The worker is therefore the one process that calls a client back, and the one that writes the journal. A provider whose worker stops goes on answering requests, and the broker keeps the messages until a worker consumes them.
The lanes are transports of RabbitMQ: each of them names the exchange beffroi_messages and a queue bound to
its routing key, options an AMQP transport takes and the Doctrine transport refuses. What fails for good lands
in the failure transport, failed, which is a table of the database whatever the broker.
What the processes share¶
The server signs with keys the worker rotates, and the worker enriches with databases the console downloads, so some state has to be the same for all of them.
| What | Where | Written by | Read by |
|---|---|---|---|
| The sealed keys of the tenant, and its pairwise salt | var/keys |
beffroi:keys:*, the timed step of the key lifecycle, and the first process that needs the salt |
Every process that signs, decrypts or seals |
| The key of the file backend, when that is the key management service | var/kms |
Whoever provisions it | Every process |
| The GeoLite2 databases of addresses | var/geoip |
beffroi:geoip:refresh, the weekly step |
The worker, which enriches a provenance |
| The FIDO metadata | var/webauthn |
beffroi:webauthn:mds-refresh, beffroi:webauthn:mds-import |
The passkey ceremonies, which check an attestation against it |
The development stack gives var/keys, var/kms and var/geoip a volume each, mounted on the server and on
the worker alike: a rotation done by one process changes nothing for another that does not see the same
directory.
The bundle keeps eight caches in pools of their own, so that a deployment can point them at a shared store without touching the rest of its cache:
| Pool | What it holds |
|---|---|
beffroi.client_keys |
The keys read from the jwks_uri of a client |
beffroi.issuer_keys |
The keys read from the jwks_uri of a trusted issuer |
beffroi.client_assertions |
The client assertions already seen |
beffroi.issuer_assertions |
The assertions of trusted issuers already seen |
beffroi.software_statements |
The software statements already seen |
beffroi.request_objects |
The request objects already seen |
beffroi.dpop |
What DPoP remembers of a proof |
beffroi.load |
What each load budget has been asked |
Each of them is on cache.app, which the application leaves on the filesystem adapter Symfony gives it, in
%kernel.share_dir%/pools/app. The share directory carries the environment, so with the APP_SHARE_DIR of
.env the pools of a production kernel are in var/share/prod/pools/app. Two containers share these pools
when they share that directory, or once a deployment points the pools at a store they both reach, and each
holds its own otherwise. The application names no session handler either, so the session of a browser lives
where the native handler of PHP keeps it, in the container that served it.
TLS and the two hosts¶
Caddy terminates TLS for the host of SERVER_NAME. Both hosts of the image use one snippet of its
Caddyfile, which is the TLS a FAPI 1.0 plan measures: an RSA key of 2048 bits, and below TLS 1.3 the two
suites of FAPI 1.0 Advanced Final §8.5 a listener built on Go can offer,
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 and TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384. TLS 1.2 is the floor
Caddy already holds.
The host that asks for a client certificate is a host of its own (RFC 8705 §5). It is served when
CADDY_EXTRA_CONFIG imports mtls.Caddyfile, under the name MTLS_SERVER_NAME gives it, and announced to
clients through BEFFROI_MTLS_ORIGIN. Its listener asks for a certificate and serves the connection when none
is presented, so a client that authenticates another way never fails at the handshake. The ordinary host asks
for nothing.
- The certificate reaches PHP in a server variable,
SSL_CLIENT_CERT, and never in a header. A client sets every header of its request and no variable of the process that serves it. A request carrying a certificate inSsl-Client-Cert,X-Ssl-Client-Cert,X-Client-CertorX-Forwarded-Client-Certis refused withinvalid_request(RFC 6749 §5.2) at an endpoint that reads a certificate. The TLS connection of that host therefore ends at this listener. - Only the leaf reaches PHP, so no chain is validated there. The handshake proves that the client holds
the private key of what it presented (RFC 8446 §4.4.3), which is what
self_signed_tls_client_auth(RFC 8705 §2.2) needs. A deployment that authenticates clients withtls_client_auth(RFC 8705 §2.1) adds atrust_poolto theclient_authblock ofmtls.Caddyfile, so the chain is validated at the edge.
The variables of the web server
Behind a proxy¶
The address of a request is read by Symfony behind the proxies it trusts, and the application names none:
framework.trusted_proxies reads SYMFONY_TRUSTED_PROXIES. That address is what the load budget of an origin
is counted under, 2400 requests a minute by default before a 429, and what every session and every act of
the journal records as where it came from. Behind a proxy that is not named there, every request has the
address of the proxy.
The headers¶
Two layers write the security headers, and where they meet, one gives way. The bundle gives every page of
the interaction plane X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store and
the floor of a content policy, frame-ancestors 'none'. The application writes the headers of a deployment on
every answer, from two files of its configuration, config/packages/nelmio_security.yaml and
config/packages/nelmio_cors.yaml:
X-Frame-Optionsis left as a page already carries it, andReferrer-Policyis written again with the value the pages carry,no-referrer;- on a page whose content policy is the floor alone,
App\Http\DeploymentPolicyPrevailsdrops the floor before the policy of the deployment is written, so one policy is sent. A page that computed a whole policy of its own keeps it, the form post response and the front-channel logout page, and so does a redirection.
A header written twice is not replaced, it collides: two content policies are both enforced, and what applies
is their intersection. Nothing the application writes is therefore written again in the web server or at the
edge. The assets compiled into public/assets/ are answered by FrankenPHP without asking PHP, and carry none
of these headers.
The session cookie is the one header of the provider no bundle sets, so the application names it:
__Host-beffroi_session, Secure, HttpOnly, SameSite=Lax, on the path / and with no Domain. HSTS is
not sent in the development environment, where it would pin every .localhost name of the machine to HTTPS.
What leaves the provider¶
A deployment that filters what goes out reads it here. No outbound redirect is followed, whoever asks.
| From | To | When |
|---|---|---|
| Every process | The key management service | To open a key, a salt or a data key, and to seal one |
| The server | The jwks_uri of a client or of a trusted issuer |
To read its keys, which are then kept a day, and again when an assertion names a key the kept set does not hold |
| The server | The request_uri of a client |
When an authorization request names one |
| The server | The sector_identifier_uri of a client |
When a client registers or replaces itself |
| The server, the console | The list of compromised passwords, when one is named | When a password is written, and the answer is waited for |
| The worker | The list of compromised passwords, when one is named | After a login, for a password already posed |
| The worker | The backchannel_logout_uri of a client |
When a session that client signed in ends |
| The worker | The backchannel_client_notification_endpoint of a client |
When a backchannel request in ping mode is answered |
| The worker | The push service of a browser | When a backchannel request waits on the person who subscribed that browser |
| The worker, the console | The metadata of the SAML peers given by URL | Every hour, and on beffroi:saml:*:refresh |
| The worker, the console | MaxMind, when a licence key is named | Every week, and on beffroi:geoip:refresh |
| The console | The FIDO Metadata Service | On beffroi:webauthn:mds-refresh |