commit 8e7245c57ac5f31374103a0298ddbe22e2acf20f
parent d6ad9388d1e90f35b1355377630919c153a6c9e9
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 7 Aug 2026 19:16:18 +0200
update paivana docs
Diffstat:
4 files changed, 177 insertions(+), 8 deletions(-)
diff --git a/design-documents/076-paywall-proxy.rst b/design-documents/076-paywall-proxy.rst
@@ -224,7 +224,7 @@ Problems:
Accepted risks:
---------------
-Three properties of this design were examined and deliberately kept as they
+Four properties of this design were examined and deliberately kept as they
are. Each is recorded here together with the assumption that makes it
acceptable, because a deployment that does not satisfy the assumption does
not get the property.
@@ -336,6 +336,34 @@ chosen by a person**; where a person chooses it, the cookies are forgeable
and the paywall is decorative. How to generate such a secret is operator
guidance and belongs with the manual rather than here.
+Truncation is unreportable to an HTTP/1.0 client
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+paivana-httpd relays bodies as they arrive rather than assembling them
+first, which is what lets it serve content larger than memory and lets the
+client start receiving before the upstream has finished. The cost is that
+the upstream's status line and headers reach the client long before the
+body is complete, so an upstream that fails mid-body cannot be reported as
+``502`` — that status has already been spent.
+
+What is left is to break the framing, which for almost every client is
+enough: a declared ``Content-Length`` is left unmet, or a chunked response
+is closed without its terminating chunk, and RFC 9112 section 8.1.2
+requires a recipient to treat either as a failed message. The exception is
+an HTTP/1.0 client receiving a response whose length the upstream never
+declared. Such a client cannot be sent chunks, so the close of the
+connection *is* the end-of-body marker, and a truncated body is
+byte-for-byte indistinguishable from a complete one.
+
+The alternative would be to buffer each response until it is known to be
+complete, which is exactly the property being given up, and which bounds
+every response by memory to buy correct reporting for one obsolete client
+version. **The risk is accepted on the assumption that clients speak
+HTTP/1.1**, which every browser and every HTTP library in current use has
+done since well before this design; where a genuine HTTP/1.0 client is
+expected, the upstream should be configured to declare a
+``Content-Length``, which restores detection for it too.
+
Implementation:
---------------
diff --git a/frags/paivana-httpd-manual.rst b/frags/paivana-httpd-manual.rst
@@ -286,7 +286,9 @@ like this:
The exhaustive list of supported keys (``SERVE``, ``PORT``,
``BIND_TO``, ``UNIXPATH``, ``UNIXPATH_MODE``, ``CONNECTION_LIMIT``,
-``PER_IP_CONNECTION_LIMIT``, ``BASE_URL``,
+``PER_IP_CONNECTION_LIMIT``, ``MAX_REQUEST_SIZE``,
+``REQUEST_BUFFER_MAX``, ``RESPONSE_BUFFER_MAX``, ``UPSTREAM_TIMEOUT``,
+``UPSTREAM_STALL_TIMEOUT``, ``BASE_URL``,
``DESTINATION_BASE_URL``, ``DESTINATION_UNIXPATH``,
``MERCHANT_BACKEND_URL``, ``MERCHANT_BACKEND_UNIX_PATH``, ``MERCHANT_ACCESS_TOKEN``,
``SECRET``, ``WHITELIST``, ``TRUSTED_PROXIES``,
@@ -322,6 +324,70 @@ a single payment per site and ``-n`` to disable the paywall for
debugging).
+Body sizes, buffering and upstream timeouts
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``paivana-httpd`` relays bodies as they arrive rather than assembling
+them first. A request or response of any size therefore passes
+through, and the client starts receiving the response while the
+upstream is still producing it — neither of which was true when each
+body had to fit in memory, where anything over 40 MiB failed outright
+and nothing reached the client until everything had.
+
+Two consequences are worth knowing before tuning anything.
+
+**Memory is bounded by the buffers, not by the content.** An
+in-flight request can hold at most ``REQUEST_BUFFER_MAX`` plus
+``RESPONSE_BUFFER_MAX`` (256 KiB each by default), so the ceiling for
+the process is that sum times ``CONNECTION_LIMIT`` — about 256 MiB at
+the shipped defaults. That is a ceiling and not a typical cost: the
+buffers are allocated on demand and grown only as far as a request
+actually needs, so a page that arrives in one read never occupies more
+than that read. Raise the buffers only to trade memory for throughput
+on fast links, and lower ``CONNECTION_LIMIT`` if you raise them a
+lot. When the client cannot keep up, Paivana stops reading
+from the upstream; when the upstream cannot keep up, Paivana stops
+reading from the client. Neither ever accumulates.
+
+**A response can now fail after its status has been sent.** Once the
+upstream's headers have gone out they cannot be retracted, so an
+upstream that dies mid-body can no longer be reported as ``502``.
+Paivana signals it by breaking the framing instead: a declared
+``Content-Length`` is left unmet, or a chunked response is closed
+without its terminating chunk. Both are failures a conforming client
+must detect (RFC 9112, section 8.1.2) — ``curl`` reports exit code
+18, for instance — and the event is logged at ``WARNING`` naming the
+URL and the byte count reached.
+
+.. note::
+
+ There is one case where it cannot be signalled at all: an HTTP/1.0
+ client receiving a response whose length the upstream never
+ declared. There the close of the connection *is* the framing, so a
+ truncated body is indistinguishable from a complete one. Nothing
+ at this layer can fix that; it is a property of HTTP/1.0. It does
+ not arise for HTTP/1.1 clients, which get chunked framing, nor for
+ any client when the upstream declares a length.
+
+The two upstream timeouts bound different things and neither bounds
+the request as a whole:
+
+- ``UPSTREAM_TIMEOUT`` (60 s) is the time to the response *headers*.
+ Expiry here yields ``504``, and this is the only one that can still
+ produce a status code.
+- ``UPSTREAM_STALL_TIMEOUT`` (60 s) is how long the upstream may move
+ no bytes in either direction. Expiry here truncates the response as
+ described above, if it had started, and yields ``504`` if it had
+ not. The clock does not run while Paivana is holding the upstream
+ back because the client has not caught up, so a slow client is never
+ mistaken for a stalled upstream.
+
+Raise ``UPSTREAM_STALL_TIMEOUT`` if your upstream legitimately pauses
+for long stretches mid-response — a report generator that computes for
+minutes between writes, say. Do not raise it to accommodate slow
+*clients*; that is already handled.
+
+
Starting and stopping the service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/manpages/paivana-httpd.1.rst b/manpages/paivana-httpd.1.rst
@@ -100,12 +100,18 @@ Its options are as follows:
reverse proxy itself.
**-u** *BYTES* | **--max-upload=**\ \ *BYTES*
- Set the maximum upload size allowed by the reverse proxy; the
- default is 1048576. Requests with a larger body are rejected with
- HTTP 413. Only applies to the body size (the HTTP header has a
- separate limit). The body is buffered in memory before it is
- forwarded and the implementation caps that buffer at 40 MiB, so a
- value above 41943040 has no effect.
+ Set how many bytes of a request body are held in memory at once
+ while it is relayed upstream; the default is 262144. Equivalent to
+ ``REQUEST_BUFFER_MAX`` in the configuration file, and like it a
+ throughput knob rather than a limit: the largest body accepted is
+ ``MAX_REQUEST_SIZE``, and bodies above that are rejected with HTTP
+ 413. Must be between 1 and 41943040.
+
+ This option used to set the limit, because a body that could not be
+ held could not be forwarded. Passing it and leaving
+ ``MAX_REQUEST_SIZE`` unset therefore still sets both, so an
+ existing invocation keeps the limit it asked for; see
+ paivana.conf(5).
**-v** | **--version**
Print version number.
diff --git a/manpages/paivana.conf.5.rst b/manpages/paivana.conf.5.rst
@@ -70,6 +70,75 @@ PER_IP_CONNECTION_LIMIT
``unix`` or ``systemd`` every client shares one peer, and behind a
reverse proxy or a NAT many clients do. This setting is optional.
+MAX_REQUEST_SIZE
+ Largest request body accepted, default 1048576. A body above this
+ is answered with HTTP 413; where the client declared a
+ ``Content-Length`` the refusal is immediate, and otherwise it
+ follows as soon as the body actually exceeds the limit. Only the
+ body is counted -- the request header block has its own limit.
+ This setting is optional.
+
+ This is a policy limit and not a memory one. Bodies are streamed,
+ so the amount Paivana holds at once is ``REQUEST_BUFFER_MAX``
+ regardless of how large this is.
+
+REQUEST_BUFFER_MAX
+ How many bytes of a request body Paivana holds in memory at once
+ while relaying it upstream, default 262144. Also settable with
+ **-u**; see paivana-httpd(1). Must be between 1 and 41943040.
+ This setting is optional.
+
+ A throughput knob rather than a limit: it bounds how far the client
+ may run ahead of the upstream before Paivana stops reading from it,
+ not how large a body may be. Larger means fewer suspend/resume
+ round trips on a fast link and more memory per request in flight;
+ the worst case is this times ``CONNECTION_LIMIT``.
+
+ Before request bodies were streamed this key did both jobs, because
+ a body that could not be held could not be forwarded. For the sake
+ of configurations written then, setting this and **not** setting
+ ``MAX_REQUEST_SIZE`` still sets both: such a file keeps the limit it
+ asked for, at the cost of a correspondingly large buffer. Set both
+ explicitly to separate them.
+
+RESPONSE_BUFFER_MAX
+ As ``REQUEST_BUFFER_MAX``, for the response body travelling the
+ other way, default 262144. This setting is optional.
+
+ There is deliberately no ``MAX_RESPONSE_SIZE`` to go with it. A
+ response is relayed as it arrives and never assembled, so its size
+ is bounded by nothing in Paivana; an operator who wants a ceiling on
+ what their own upstream serves can impose it at the upstream.
+
+UPSTREAM_TIMEOUT
+ How long the upstream has to produce its response *headers* before
+ the request is answered with HTTP 504, default ``60 s``. This
+ setting is optional.
+
+ Not a bound on how long a request may take: a large download runs
+ for as long as it runs. The clock stops the moment the header
+ section ends, and this is the only one of the two upstream timeouts
+ that can still produce a status code -- after it, the status has
+ already gone to the client. A stalling upstream is caught by
+ ``UPSTREAM_STALL_TIMEOUT`` instead.
+
+UPSTREAM_STALL_TIMEOUT
+ How long the upstream may move no bytes at all, in either
+ direction, before Paivana gives up on the request, default
+ ``60 s``. This setting is optional.
+
+ The clock does not run while Paivana is itself the reason nothing is
+ moving -- that is, while it has stopped reading from the upstream
+ because the client has not drained what already arrived. A client
+ on a slow link is therefore never mistaken for a slow upstream.
+
+ What expiry does to the client depends on how far the response had
+ got. Before the upstream's headers were relayed, the client gets
+ HTTP 504. After them the status has already been sent and cannot be
+ retracted, so the response is truncated instead: a declared
+ ``Content-Length`` is left unmet, or a chunked response is closed
+ without its terminating chunk. The Paivana manual has the details.
+
TRUSTED_PROXIES
IPv4 networks whose members are reverse proxies trusted to report
the client address in the ``Forwarded`` or ``X-Forwarded-For``