commit ec75766944169e42f22fd406dfcebc79038cbf61
parent b5162388d5ee344d126d07bd84f528459812e1e8
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 7 Aug 2026 16:43:33 +0200
expand cookie tests
Diffstat:
5 files changed, 1239 insertions(+), 209 deletions(-)
diff --git a/src/tests/README b/src/tests/README
@@ -1,17 +1,22 @@
paivana tests
=============
-This directory contains two test programs:
+This directory contains four test programs:
reverse_proxy an integration suite for the reverse-proxy side of
paivana-httpd, driven by test_reverse_proxy.sh
client_address a unit test for the client address the access
cookie is keyed on (test_client_address.c)
+ cookie_header a unit test for the `Set-Cookie` line paivana emits
+ for that cookie (test_cookie_header.c)
+ cookie_access a unit test for the access decision the cookie
+ value encodes, and for the `paivana_id` the order
+ is created under (test_cookie_access.c)
The integration suite runs paivana-httpd with `-n` (paywall disabled)
so no merchant backend is required: it only verifies that the proxy
correctly forwards HTTP requests and responses. Everything below
-describes that suite except the "client_address" section at the end.
+describes that suite except the three unit-test sections at the end.
What gets built
---------------
@@ -160,9 +165,35 @@ Forwarding-header tests (run once):
address of the wrong family, junk) must
abort startup rather than silently
trusting nobody; usable ones must start.
+ WHITELIST startup an expression regcomp(3) cannot compile
+ must abort startup rather than leave
+ paivana matching against an
+ uninitialised regex_t; usable ones must
+ start. Only the loading is reachable
+ from here: the regexec sits behind the
+ paywall that `-n` switches off, and
+ without `-n` paivana needs a merchant
+ backend to serve it templates before it
+ will start at all. So the anchoring
+ that keeps a WHITELIST of "/free/" from
+ waiving payment for every URL merely
+ containing it still has no regression
+ test.
Cross-cutting tests (run once):
+ POST /.well-known/paivana with `-n` the payment endpoint answers
+ 501 rather than falling through to the
+ proxy -- the one paywall-side branch
+ `-n` does not shield. Both ways to get
+ it wrong are silent: forwarding the POST
+ would hand the origin payment data it
+ has no business seeing, and claiming the
+ path for every method would shadow
+ whatever the origin serves there, so the
+ GET of the same path is checked to still
+ be forwarded.
+
TRACE method unsupported HTTP verb yields 405 Method
Not Allowed (paivana rejects it, the
upstream is never contacted)
@@ -304,6 +335,42 @@ and no merchant backend. The integration suite cannot cover any of
this: with `-n` the cookie path is never reached, so the client
address is never computed.
+The cookie unit tests
+---------------------
+
+The same applies to the two cookie tests, and for the same reason:
+`-n` sets do_forward before the request is looked at, so nothing in
+the integration suite ever mints or checks a cookie. Both link only
+paivana-httpd_cookie.c.
+
+`test_cookie_header.c` is about the header paivana emits, i.e. about
+whether the credential the client just paid for ever comes back: the
+`Path` re-encoding (the browser matches against the encoded request
+path, while the URL paivana holds has been decoded by MHD), the RFC
+6265 ยง4.1.1 grammar the attribute has to satisfy, attribute injection
+through a path containing ';', `Secure`, and the `Max-Age` floor that
+keeps a sub-second access from being deleted on arrival.
+
+`test_cookie_access.c` is about the decision made when it does come
+back. The cookie is a bearer token we hand to the party most
+interested in widening it, so each of the three things it is minted
+for -- expiration, website, client address -- is checked to be inside
+the MAC and re-checked on presentation, including the obvious attempt:
+reading the expiration off the value and writing a later one. Each of
+the four ways check_cookie() can reject a value has its own case, so
+that a malformed value ends in a refusal rather than in a read past
+the end of a string the client chose. `-g` is covered here and
+nowhere else.
+
+The `paivana_id` is pinned against a golden vector computed
+independently from the definition the paywall page implements
+(src/frontend/paywall.js, makePaivanaId()). Neither side ever sends
+it; both derive it from their own copy of (nonce, website, expiration)
+and expect the other to have got the same string, so the two
+implementations agreeing IS the protocol, and a change on either side
+that this vector does not survive means every order is created under
+an id the other side will not look for.
+
Environment variables
---------------------
@@ -315,22 +382,42 @@ The driver script honors:
BUILDDIR directory containing upstream_mhd, pipeline_client,
upstream_go, upstream_rs (default: $PWD)
KEEP_TMP=1 do not delete the scratch dir on exit
+ PAIVANA_PORT_BASE
+ first port of the block the suite binds
+ (default 18400); see below
Ports used
----------
-All ports are in the 184xx / 185xx range to avoid collisions with
-real services. They are fixed; the suite exits early if any of them
-are already in use.
-
- 18401 upstream_mhd
- 18402 upstream_go
- 18403 upstream_py
- 18404 upstream_rs
- 18405 early_response_upstream
- 18406 early_response_upstream --no-drain
- 18499 dead port (for "upstream down" test)
- 18500 paivana-httpd
+Every port is an offset off PAIVANA_PORT_BASE, which defaults to 18400
+-- the 184xx / 185xx range, chosen to avoid collisions with real
+services. The suite checks all nine before it starts anything and
+exits 77 (meson reads that as SKIP) if one of them is taken, naming
+it.
+
+That check is not a formality. Readiness used to be "does something
+accept on this port", which is a different question from "did our
+child come up": a paivana that lost the bind to a squatter -- most
+often a stale one of its own from an earlier run -- read as started,
+and the suite then ran its checks against the wrong process. With a
+stale paivana of a different vintage they even pass. The startup
+validation cases are the worst affected, since those decide "refused"
+from exactly that probe.
+
+ base + 1 (18401) upstream_mhd
+ base + 2 (18402) upstream_go
+ base + 3 (18403) upstream_py
+ base + 4 (18404) upstream_rs
+ base + 5 (18405) early_response_upstream
+ base + 6 (18406) early_response_upstream --no-drain
+ base + 7 (18407) truncating upstream (short-body test)
+ base + 99 (18499) dead port (for "upstream down" test)
+ base + 100 (18500) paivana-httpd
+
+Move the base to run the suite in two checkouts at once, or beside a
+paivana you are debugging:
+
+ PAIVANA_PORT_BASE=18700 meson test -C build reverse_proxy
Endpoints (implemented by every upstream)
-----------------------------------------
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -76,6 +76,31 @@ test_cookie_header = executable(
test('cookie_header', test_cookie_header)
+# Unit test for the access decision the cookie encodes (the time bound,
+# the three MAC inputs, the four rejection paths) and for the
+# `paivana_id`. Same shape as the above: only the cookie compilation
+# unit, with the daemon's globals supplied by the test.
+test_cookie_access = executable(
+ 'test_cookie_access',
+ [
+ 'test_cookie_access.c',
+ '../backend/paivana-httpd_cookie.c',
+ ],
+ dependencies: [
+ talerutil_dep,
+ talermhd_dep,
+ gnunetutil_dep,
+ gcrypt_dep,
+ mhd_dep,
+ json_dep,
+ curl_dep,
+ ],
+ include_directories: [incdir, configuration_inc, paivana_backend_inc],
+ install: false,
+)
+
+test('cookie_access', test_cookie_access)
+
test_deps = [
upstream_mhd,
pipeline_client,
diff --git a/src/tests/test_cookie_access.c b/src/tests/test_cookie_access.c
@@ -0,0 +1,708 @@
+/*
+ This file is part of GNUnet.
+ Copyright (C) 2026 Taler Systems SA
+
+ Paivana is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Affero General Public License
+ as published by the Free Software Foundation; either version
+ 3, or (at your option) any later version.
+
+ Paivana is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty
+ of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+ the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public
+ License along with Paivana; see the file COPYING. If not,
+ write to the Free Software Foundation, Inc., 51 Franklin
+ Street, Fifth Floor, Boston, MA 02110-1301, USA.
+*/
+
+/**
+ * @file test_cookie_access.c
+ * @brief tests the access decision itself: which cookie values
+ * PAIVANA_HTTPD_check_cookie() grants access on, and the
+ * `paivana_id` the order is created under
+ *
+ * Its sibling test_cookie_header.c covers the `Set-Cookie` line paivana
+ * emits -- whether the credential ever comes back. This one covers
+ * what happens when it does, which is the security boundary: the cookie
+ * is a bearer token minted by us and handed to the party with the
+ * strongest possible interest in widening it, so every one of
+ *
+ * - the expiration it was minted for,
+ * - the website it was minted for,
+ * - the client address it was minted for
+ *
+ * has to be inside the MAC and has to be re-checked, and each of the
+ * four ways a value can be malformed has to end in a rejection rather
+ * than in a read of something that is not there. None of this is
+ * reachable from the integration suite: that runs paivana with -n,
+ * where do_forward is set before the request is even looked at and the
+ * cookie path is never entered.
+ *
+ * `expiration' is the subject of the second half. It used to be called
+ * `cur_time' and was read in two mutually exclusive ways at once -- as
+ * the client's clock, bounded to 90 seconds ahead, and as the end of
+ * the access being sold, which is what the cookie's Max-Age, this
+ * file's time bound and the contract's max_pickup_time all treat it as.
+ * The cases below pin the surviving reading: a cookie minted for an
+ * hour out is good for an hour (under the other reading it could not be
+ * minted at all), one minted for a moment already past is good for
+ * nothing, and moving the expiration in the value breaks the MAC.
+ */
+#include "platform.h"
+#include <gnunet/gnunet_util_lib.h>
+#include "paivana-httpd_cookie.h"
+
+/**
+ * Globals that paivana-httpd.c normally defines; the cookie
+ * compilation unit references them.
+ */
+int PH_global_cookie;
+char *PH_base_url;
+
+/**
+ * Number of checks that did not hold.
+ */
+static unsigned int failures;
+
+/**
+ * The website most cases are about.
+ */
+#define WEBSITE "https://example.com/premium/article"
+
+/**
+ * A client address, in the shape get_client_address() hands over for
+ * an IPv4 peer: the bare address bytes.
+ */
+static const uint8_t ca4[4] = { 203, 0, 113, 7 };
+
+/**
+ * A different client address of the same length.
+ */
+static const uint8_t ca4b[4] = { 203, 0, 113, 8 };
+
+/**
+ * An IPv6 client address, i.e. one of a different length.
+ */
+static const uint8_t ca6[16] = {
+ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x07
+};
+
+
+/**
+ * Mint an access cookie and return its value, i.e. the `Set-Cookie`
+ * line with the name and the attributes cut away -- what the browser
+ * will send back in a `Cookie` header and what check_cookie() is
+ * handed.
+ *
+ * @param website URL the cookie is minted for
+ * @param expiration end of the access being granted
+ * @param ca_len number of bytes in @a ca
+ * @param ca client address the cookie is minted for
+ * @return the cookie value, to be freed by the caller
+ */
+static char *
+mint (const char *website,
+ struct GNUNET_TIME_Timestamp expiration,
+ size_t ca_len,
+ const void *ca)
+{
+ char *sc;
+ char *eq;
+ char *semi;
+ char *val;
+
+ sc = PAIVANA_HTTPD_compute_cookie (expiration,
+ website,
+ ca_len,
+ ca);
+ eq = strchr (sc,
+ '=');
+ GNUNET_assert (NULL != eq);
+ semi = strchr (eq,
+ ';');
+ if (NULL != semi)
+ *semi = '\0';
+ val = GNUNET_strdup (eq + 1);
+ GNUNET_free (sc);
+ return val;
+}
+
+
+/**
+ * Mint an access cookie for #WEBSITE, an hour out, for #ca4.
+ *
+ * @return the cookie value, to be freed by the caller
+ */
+static char *
+mint_default (void)
+{
+ return mint (WEBSITE,
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ sizeof (ca4),
+ ca4);
+}
+
+
+/**
+ * Check that @a value is (or is not) accepted for @a website and @a ca.
+ *
+ * @param label what the case is called in the log
+ * @param value cookie value to present
+ * @param website URL the access is claimed for
+ * @param ca_len number of bytes in @a ca
+ * @param ca client address the access is claimed from
+ * @param want true if the cookie is expected to grant access
+ */
+static void
+grants (const char *label,
+ const char *value,
+ const char *website,
+ size_t ca_len,
+ const void *ca,
+ bool want)
+{
+ bool got;
+
+ got = PAIVANA_HTTPD_check_cookie (value,
+ website,
+ ca_len,
+ ca);
+ if (got != want)
+ {
+ fprintf (stderr,
+ "FAIL: %s -> %s, want %s\n",
+ label,
+ got ? "granted" : "refused",
+ want ? "granted" : "refused");
+ failures++;
+ }
+ else
+ {
+ fprintf (stderr,
+ " ok: %s -> %s\n",
+ label,
+ got ? "granted" : "refused");
+ }
+}
+
+
+/**
+ * Check that @a value is refused, whatever it is. Used for the
+ * malformed cases, where the point is that nothing is read past the
+ * end of a value the client chose.
+ *
+ * @param label what the case is called in the log
+ * @param value cookie value to present
+ */
+static void
+refuses (const char *label,
+ const char *value)
+{
+ grants (label,
+ value,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ false);
+}
+
+
+/**
+ * Check that @a id has the shape the paywall page and the merchant
+ * both expect: decimal seconds, '-', then unpadded RFC 4648 section 5
+ * base64url. It travels as a merchant `session_id` and, before that,
+ * through a URL.
+ *
+ * @param label what the case is called in the log
+ * @param id the identifier to inspect
+ */
+static void
+id_is_wellformed (const char *label,
+ const char *id)
+{
+ const char *dash;
+
+ dash = strchr (id,
+ '-');
+ if ( (NULL == dash) ||
+ (dash == id) )
+ {
+ fprintf (stderr,
+ "FAIL: %s: `%s' has no seconds prefix\n",
+ label,
+ id);
+ failures++;
+ return;
+ }
+ for (const char *p = id; p < dash; p++)
+ {
+ if (! isdigit ((unsigned char) *p))
+ {
+ fprintf (stderr,
+ "FAIL: %s: `%s' has a non-digit in its seconds prefix\n",
+ label,
+ id);
+ failures++;
+ return;
+ }
+ }
+ for (const char *p = dash + 1; '\0' != *p; p++)
+ {
+ if ( (isalnum ((unsigned char) *p)) ||
+ ('-' == *p) ||
+ ('_' == *p) )
+ continue;
+ fprintf (stderr,
+ "FAIL: %s: `%s' carries `%c', which is not in the base64url"
+ " alphabet\n",
+ label,
+ id,
+ *p);
+ failures++;
+ return;
+ }
+ fprintf (stderr,
+ " ok: %s -> %s\n",
+ label,
+ id);
+}
+
+
+/**
+ * Check that @a a and @a b differ.
+ *
+ * @param label what the case is called in the log
+ * @param a first identifier
+ * @param b second identifier
+ */
+static void
+differ (const char *label,
+ const char *a,
+ const char *b)
+{
+ if (0 == strcmp (a,
+ b))
+ {
+ fprintf (stderr,
+ "FAIL: %s: both inputs give `%s'\n",
+ label,
+ a);
+ failures++;
+ }
+ else
+ {
+ fprintf (stderr,
+ " ok: %s\n",
+ label);
+ }
+}
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ struct GNUNET_TIME_Timestamp hour;
+ struct GNUNET_TIME_Timestamp past;
+ char *val;
+
+ (void) argc;
+ (void) argv;
+ /* Quiet: every refusal below logs by design. */
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_log_setup ("test-cookie-access",
+ "ERROR",
+ NULL));
+ GNUNET_CRYPTO_hash ("test-cookie-access",
+ strlen ("test-cookie-access"),
+ &paivana_secret);
+ hour = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS);
+ /* A fixed instant in the past (2023-11-14), so that the expiry cases
+ do not depend on how long the test takes to run. */
+ past.abs_time = GNUNET_TIME_absolute_from_s (1700000000);
+
+ fprintf (stderr,
+ "-- a cookie grants what it was minted for --\n");
+ val = mint_default ();
+ grants ("the cookie just minted",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ true);
+ GNUNET_free (val);
+
+ fprintf (stderr,
+ "-- ...and expires when it says it does --\n");
+ /* An hour is an ordinary access duration and, before `expiration'
+ was named that, an unreachable one: the pay endpoint refused any
+ value more than 90 seconds ahead, so this cookie could not be
+ obtained at all. */
+ val = mint (WEBSITE,
+ hour,
+ sizeof (ca4),
+ ca4);
+ grants ("an expiration an hour out",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ true);
+ GNUNET_free (val);
+ /* compute_cookie() raises a sub-second Max-Age to 1, since a Max-Age
+ of 0 would delete the cookie on arrival (RFC 6265 section 5.2.2).
+ That is a floor on the attribute, not on the access: the value
+ itself still carries the expiration, and this side must not honour
+ a second the other side rounded up to. */
+ val = mint (WEBSITE,
+ past,
+ sizeof (ca4),
+ ca4);
+ grants ("an expiration in the past",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ false);
+ GNUNET_free (val);
+
+ fprintf (stderr,
+ "-- the expiration is inside the MAC --\n");
+ {
+ unsigned long long secs;
+ const char *dash;
+ char *forged;
+
+ /* The holder of a cookie can read its expiration off the value and
+ would like a later one. Rewriting it has to fail: the timestamp
+ is the KDF salt, so a value whose prefix no longer matches what
+ was signed cannot verify. */
+ val = mint_default ();
+ GNUNET_assert (1 ==
+ sscanf (val,
+ "%llu-",
+ &secs));
+ dash = strchr (val,
+ '-');
+ GNUNET_assert (NULL != dash);
+ GNUNET_asprintf (&forged,
+ "%llu%s",
+ secs + 365ULL * 24 * 60 * 60,
+ dash);
+ grants ("an expiration moved a year forward",
+ forged,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ false);
+ GNUNET_free (forged);
+ GNUNET_free (val);
+ }
+
+ fprintf (stderr,
+ "-- the website is inside the MAC --\n");
+ val = mint_default ();
+ grants ("presented for a different site",
+ val,
+ "https://example.com/premium/other",
+ sizeof (ca4),
+ ca4,
+ false);
+ grants ("presented for a different host",
+ val,
+ "https://evil.example.net/premium/article",
+ sizeof (ca4),
+ ca4,
+ false);
+ grants ("presented for a different scheme",
+ val,
+ "http://example.com/premium/article",
+ sizeof (ca4),
+ ca4,
+ false);
+ GNUNET_free (val);
+
+ fprintf (stderr,
+ "-- the client address is inside the MAC --\n");
+ /* This is what stops a paid cookie from being passed around, and it
+ is why get_client_address() has to be right about who the client
+ is: whoever the address says paid, paid for everyone at it. */
+ val = mint_default ();
+ grants ("presented from a neighbouring address",
+ val,
+ WEBSITE,
+ sizeof (ca4b),
+ ca4b,
+ false);
+ grants ("presented from an address of a different family",
+ val,
+ WEBSITE,
+ sizeof (ca6),
+ ca6,
+ false);
+ grants ("presented from no address at all",
+ val,
+ WEBSITE,
+ 0,
+ "",
+ false);
+ GNUNET_free (val);
+ /* An IPv6 client is not a special case, just a longer one. */
+ val = mint (WEBSITE,
+ hour,
+ sizeof (ca6),
+ ca6);
+ grants ("an IPv6 cookie for its own address",
+ val,
+ WEBSITE,
+ sizeof (ca6),
+ ca6,
+ true);
+ grants ("an IPv6 cookie for a truncation of it",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca6,
+ false);
+ GNUNET_free (val);
+
+ fprintf (stderr,
+ "-- malformed values are refused, not parsed --\n");
+ /* Every one of these is a string an attacker can put in a Cookie
+ header, and each takes a different one of the four exits in
+ check_cookie(). */
+ refuses ("the empty value",
+ "");
+ refuses ("a value with no '-' at all",
+ "deadbeef");
+ refuses ("a '-' with no seconds before it",
+ "-ABCDEF");
+ refuses ("a non-numeric prefix",
+ "soon-ABCDEF");
+ {
+ unsigned long long secs;
+ char *bad;
+
+ /* A well-formed, unexpired prefix followed by something that is
+ not the base32 encoding of a hash: the length check and the
+ alphabet check both live in string_to_data(). */
+ secs = (unsigned long long)
+ (GNUNET_TIME_timestamp_to_s (hour));
+ GNUNET_asprintf (&bad,
+ "%llu-",
+ secs);
+ refuses ("a prefix with an empty hash",
+ bad);
+ GNUNET_free (bad);
+ GNUNET_asprintf (&bad,
+ "%llu-!!!!!!!!",
+ secs);
+ refuses ("a hash outside the base32 alphabet",
+ bad);
+ GNUNET_free (bad);
+ GNUNET_asprintf (&bad,
+ "%llu-ABCDEFGH",
+ secs);
+ refuses ("a hash of the wrong length",
+ bad);
+ GNUNET_free (bad);
+ }
+ {
+ char *val2;
+ char *swapped;
+ const char *dash;
+ unsigned long long secs;
+
+ /* A syntactically perfect value whose hash simply is not ours:
+ the last exit, the one the MAC comparison itself takes. */
+ val = mint_default ();
+ val2 = mint (WEBSITE,
+ hour,
+ sizeof (ca4b),
+ ca4b);
+ dash = strchr (val2,
+ '-');
+ GNUNET_assert (NULL != dash);
+ GNUNET_assert (1 ==
+ sscanf (val,
+ "%llu-",
+ &secs));
+ GNUNET_asprintf (&swapped,
+ "%llu%s",
+ secs,
+ dash);
+ refuses ("another client's hash under our own timestamp",
+ swapped);
+ GNUNET_free (swapped);
+ GNUNET_free (val2);
+ GNUNET_free (val);
+ }
+
+ fprintf (stderr,
+ "-- -g scopes the cookie to the whole deployment --\n");
+ /* With PH_global_cookie the website drops out of the KDF, so one
+ cookie is good everywhere paivana serves. That is the point of
+ the switch; what matters is that it is symmetric, i.e. that
+ flipping it invalidates the cookies minted under the other
+ setting rather than silently widening or narrowing access. */
+ PH_global_cookie = 1;
+ val = mint (WEBSITE,
+ hour,
+ sizeof (ca4),
+ ca4);
+ grants ("-g: a cookie minted elsewhere on the site",
+ val,
+ "https://example.com/other/article",
+ sizeof (ca4),
+ ca4,
+ true);
+ grants ("-g: still bound to the client address",
+ val,
+ "https://example.com/other/article",
+ sizeof (ca4b),
+ ca4b,
+ false);
+ PH_global_cookie = 0;
+ grants ("-g cookie presented after -g was turned off",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ false);
+ GNUNET_free (val);
+ val = mint (WEBSITE,
+ hour,
+ sizeof (ca4),
+ ca4);
+ PH_global_cookie = 1;
+ grants ("non-global cookie presented after -g was turned on",
+ val,
+ WEBSITE,
+ sizeof (ca4),
+ ca4,
+ false);
+ PH_global_cookie = 0;
+ GNUNET_free (val);
+
+ fprintf (stderr,
+ "-- the paivana_id --\n");
+ {
+ struct PAIVANA_Nonce nonce;
+ struct PAIVANA_Nonce nonce2;
+ static const uint8_t nonce_bytes[16] = {
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
+ };
+ /* Computed independently from the same definition the paywall page
+ implements (src/frontend/paywall.js makePaivanaId()):
+ base64url (SHA-256 (nonce || website || '\0' || be64 (usec)))
+ with the padding stripped, prefixed by the expiration in seconds.
+ Both ends derive this from their own copy of the inputs and never
+ exchange it, so the two implementations agreeing is the whole
+ protocol: a change on either side that this vector does not
+ survive means every order is created under an id the other side
+ will not look for. Note that the client hashes the expiration,
+ which is why the pay endpoint cannot re-derive it and has to take
+ the client's word for it (bounded by the contract). */
+ static const char *want =
+ "1700000000-gAGOp3pVpQq9VTA7P0cYqqKTiFGLxC_9FLZZXkmZwGc";
+ char *id;
+ char *id2;
+
+ /* memcpy rather than an initialiser list: the nonce is hashed as
+ the bytes it is in memory, and a `uint32_t val[4]' written as
+ integers would give a different digest on a big-endian host. */
+ memcpy (&nonce,
+ nonce_bytes,
+ sizeof (nonce));
+ id = PAIVANA_HTTPD_compute_paivana_id (past,
+ WEBSITE,
+ &nonce);
+ if (0 != strcmp (id,
+ want))
+ {
+ fprintf (stderr,
+ "FAIL: paivana_id is `%s', want `%s'\n",
+ id,
+ want);
+ failures++;
+ }
+ else
+ {
+ fprintf (stderr,
+ " ok: paivana_id matches the paywall page's derivation\n");
+ }
+ id_is_wellformed ("the shape of a paivana_id",
+ id);
+
+ /* All three inputs have to reach the digest. */
+ id2 = PAIVANA_HTTPD_compute_paivana_id (hour,
+ WEBSITE,
+ &nonce);
+ differ ("a different expiration gives a different id",
+ id,
+ id2);
+ GNUNET_free (id2);
+ id2 = PAIVANA_HTTPD_compute_paivana_id (past,
+ "https://example.com/premium/other",
+ &nonce);
+ differ ("a different website gives a different id",
+ id,
+ id2);
+ GNUNET_free (id2);
+ memcpy (&nonce2,
+ nonce_bytes,
+ sizeof (nonce2));
+ nonce2.val[3] ^= 1;
+ id2 = PAIVANA_HTTPD_compute_paivana_id (past,
+ WEBSITE,
+ &nonce2);
+ differ ("a different nonce gives a different id",
+ id,
+ id2);
+ GNUNET_free (id2);
+ /* The whole website string reaches the digest, terminating NUL
+ included -- not some prefix of it. */
+ id2 = PAIVANA_HTTPD_compute_paivana_id (past,
+ WEBSITE "x",
+ &nonce);
+ differ ("a website that extends another gives a different id",
+ id,
+ id2);
+ GNUNET_free (id2);
+ /* ...and it is a pure function, or the order and the redemption
+ would never line up. */
+ id2 = PAIVANA_HTTPD_compute_paivana_id (past,
+ WEBSITE,
+ &nonce);
+ if (0 != strcmp (id,
+ id2))
+ {
+ fprintf (stderr,
+ "FAIL: paivana_id is not deterministic: `%s' then `%s'\n",
+ id,
+ id2);
+ failures++;
+ }
+ else
+ {
+ fprintf (stderr,
+ " ok: paivana_id is deterministic\n");
+ }
+ GNUNET_free (id2);
+ GNUNET_free (id);
+ }
+
+ if (0 != failures)
+ {
+ fprintf (stderr,
+ "%u check(s) failed\n",
+ failures);
+ return 1;
+ }
+ fprintf (stderr,
+ "all checks passed\n");
+ return 0;
+}
diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh
@@ -19,6 +19,9 @@
# BUILDDIR directory holding upstream_mhd, upstream_go,
# upstream_rs, pipeline_client (default: $PWD)
# KEEP_TMP=1 keep the scratch dir and log files after exit
+# PAIVANA_PORT_BASE
+# first port of the ten the suite binds (default
+# 18400); move it to run two checkouts at once
#
set -u
@@ -73,27 +76,37 @@ UPSTREAM_RS="$BUILDDIR/upstream_rs"
PIPELINE_CLIENT="$BUILDDIR/pipeline_client"
EARLY_RESPONSE_UPSTREAM="$BUILDDIR/early_response_upstream"
-# Ports (fixed, but we still wait/retry binding; if they're busy the
-# test bails out early so the user can rerun.)
-PAIVANA_PORT=18500
-MHD_PORT=18401
-GO_PORT=18402
-PY_PORT=18403
-RS_PORT=18404
-EARLY_PORT=18405
-NODRAIN_PORT=18406
-TRUNC_PORT=18407
-DEAD_PORT=18499 # nothing should be listening here
-
-TMPDIR="$(mktemp -d -t paivana-tests.XXXXXX)"
-LOGDIR="$TMPDIR/logs"
+# Ports. Every one of them is an offset off a single base so that the
+# whole block can be moved out of the way: two checkouts of this repo
+# (or two CI jobs on one machine) running the suite at once would
+# otherwise fight over the same ten fixed numbers, and the loser reads
+# as a paivana bug rather than as a collision. The default keeps the
+# historical numbering. require_ports_free() below refuses to run at
+# all when one of them is taken.
+PORT_BASE="${PAIVANA_PORT_BASE:-18400}"
+MHD_PORT=$((PORT_BASE + 1))
+GO_PORT=$((PORT_BASE + 2))
+PY_PORT=$((PORT_BASE + 3))
+RS_PORT=$((PORT_BASE + 4))
+EARLY_PORT=$((PORT_BASE + 5))
+NODRAIN_PORT=$((PORT_BASE + 6))
+TRUNC_PORT=$((PORT_BASE + 7))
+DEAD_PORT=$((PORT_BASE + 99)) # nothing may be listening here
+PAIVANA_PORT=$((PORT_BASE + 100))
+
+# NOT named TMPDIR. That is the standard variable every child process
+# reads for its own temporary files, bash keeps the export attribute an
+# inherited TMPDIR came with, and cleanup() rm -rf's this directory
+# while paivana, the upstreams and curl may still be running out of it.
+SCRATCH="$(mktemp -d -t paivana-tests.XXXXXX)"
+LOGDIR="$SCRATCH/logs"
mkdir -p "$LOGDIR"
# paivana normally resolves its config.d via the install prefix.
# Tests run against the uninstalled build tree, so point the
# project's base-config override at an empty directory: no
# auxiliary config snippets are needed for reverse-proxy tests.
-BASE_CONFIG_DIR="$TMPDIR/configd"
+BASE_CONFIG_DIR="$SCRATCH/configd"
mkdir -p "$BASE_CONFIG_DIR"
export PAIVANA_BASE_CONFIG="$BASE_CONFIG_DIR"
@@ -132,26 +145,89 @@ function cleanup() {
[ -n "$PAIVANA_PID" ] && kill -KILL "$PAIVANA_PID" 2>/dev/null
if [ "${KEEP_TMP:-0}" = "1" ];
then
- echo "Temp files kept in $TMPDIR" >&2
+ echo "Temp files kept in $SCRATCH" >&2
else
- rm -rf "$TMPDIR"
+ rm -rf "$SCRATCH"
fi
}
trap cleanup EXIT
trap 'echo "FAIL: interrupted" >&2; exit 1' INT TERM
+# Does a TCP connect to the given port fail? Used both as the
+# "nothing is squatting here" precondition and, negated, as the
+# readiness probe.
+#
+# NOTE: the /dev/tcp probe must run in a subshell โ `exec` on the
+# parent shell with a failing redirection would terminate bash in
+# non-interactive mode (the 2>/dev/null does not suppress that).
+function port_is_free() {
+ local host="$1" port="$2"
+
+ if ( exec 7<>"/dev/tcp/$host/$port" ) 2>/dev/null;
+ then
+ return 1
+ fi
+ return 0
+}
+
+function require_ports_free() {
+ # The suite's own documentation promised this and nothing did it.
+ # A stranger on PAIVANA_PORT is the damaging case: our paivana dies
+ # of EADDRINUSE, the readiness probe below sees the squatter accept
+ # and reports "started", and every check then runs against the
+ # wrong process -- with a stale paivana of a different vintage the
+ # checks even pass. DEAD_PORT has to be free for the mirror-image
+ # reason: test_upstream_down asserts that connecting to it fails.
+ #
+ # This is an environment problem rather than a regression, so it is
+ # a skip (meson reads 77 as SKIP), not a failure.
+ local busy=""
+
+ for p in "$@";
+ do
+ port_is_free 127.0.0.1 "$p" || busy="$busy $p"
+ done
+ if [ -n "$busy" ];
+ then
+ echo "SKIP: port(s) already in use:$busy" >&2
+ echo "Another copy of this suite, or a stale paivana-httpd, is" \
+ "holding them; re-run with PAIVANA_PORT_BASE set to a free" \
+ "block of 101 ports (current base: $PORT_BASE)." >&2
+ exit 77
+ fi
+}
+
function wait_for_port() {
- # Block until a TCP port accepts a connection (max ~5s).
- # NOTE: must run the /dev/tcp probe in a subshell โ `exec` on the
- # parent shell with a failing redirection would terminate bash in
- # non-interactive mode (the 2>/dev/null does not suppress that).
- local host="$1" port="$2" tries=50
+ # Block until a TCP port accepts a connection (max ~5s), giving up
+ # the moment the process that was supposed to bind it is gone.
+ #
+ # Without the liveness check this only asks "is *something*
+ # listening", which is not the same question: a child that died of
+ # EADDRINUSE (or of a config it refused) reads as started, and the
+ # caller happily runs its checks against whoever holds the port.
+ # It is also what made a refused startup cost the full 5 s of
+ # retries instead of the milliseconds the child actually took to
+ # exit -- seven of those were most of the suite's wall-clock.
+ #
+ # $3 is optional so that a caller with no pid to offer (a helper
+ # started by some other means) still works, just without either
+ # benefit.
+ local host="$1" port="$2" pid="${3:-}" tries=50
+
while [ "$tries" -gt 0 ];
do
- if ( exec 7<>"/dev/tcp/$host/$port" ) 2>/dev/null;
+ if ! port_is_free "$host" "$port";
then
return 0
fi
+ # Order matters: probe first, so that "the port is up" always
+ # wins over "the pid we were given is gone" -- a child that
+ # handed the listening socket on and exited is still a service
+ # that came up.
+ if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null;
+ then
+ return 1
+ fi
sleep 0.1
tries=$((tries - 1))
done
@@ -165,7 +241,7 @@ function start_bg() {
( exec "$@" "$port" ) >"$log" 2>&1 &
local pid=$!
PIDS+=("$pid")
- if ! wait_for_port 127.0.0.1 "$port";
+ if ! wait_for_port 127.0.0.1 "$port" "$pid";
then
echo "FAIL: $name did not start on port $port" >&2
tail -n 20 "$log" >&2
@@ -180,13 +256,13 @@ function start_paivana() {
# passes no extra flags from handing paivana an empty argument.
local dest="$1"; shift
PAIVANA_DEST="$dest"
- local cfg="$TMPDIR/paivana.conf"
+ local cfg="$SCRATCH/paivana.conf"
sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \
"$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
local log="$LOGDIR/paivana.log"
( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING "$@" ) >"$log" 2>&1 &
PAIVANA_PID=$!
- if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT";
+ if ! wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$PAIVANA_PID";
then
echo "FAIL: paivana-httpd did not start on port $PAIVANA_PORT" >&2
tail -n 20 "$log" >&2
@@ -195,11 +271,19 @@ function start_paivana() {
}
function wait_for_unix_socket() {
- # Block until the given path exists and is a socket (max ~5s).
- local path="$1" tries=50
+ # Block until the given path exists and is a socket (max ~5s), or
+ # until the process that was to create it is gone. Same reasoning
+ # as wait_for_port: a leftover socket file from an earlier run is
+ # the Unix-domain spelling of a squatter on the port.
+ local path="$1" pid="${2:-}" tries=50
+
while [ "$tries" -gt 0 ];
do
[ -S "$path" ] && return 0
+ if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null;
+ then
+ return 1
+ fi
sleep 0.1
tries=$((tries - 1))
done
@@ -213,15 +297,15 @@ function start_paivana_unix() {
# $1 = upstream base URL; further arguments go to paivana-httpd.
local dest="$1"; shift
PAIVANA_DEST="$dest"
- local cfg="$TMPDIR/paivana-unix.conf"
- PAIVANA_SOCK="$TMPDIR/paivana.sock"
+ local cfg="$SCRATCH/paivana-unix.conf"
+ PAIVANA_SOCK="$SCRATCH/paivana.sock"
rm -f "$PAIVANA_SOCK"
sed -e "s|@DEST@|$dest|g" -e "s|@UNIXPATH@|$PAIVANA_SOCK|g" \
"$SRCDIR/test_reverse_proxy_unix.conf.in" > "$cfg"
local log="$LOGDIR/paivana-unix.log"
( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING "$@" ) >"$log" 2>&1 &
PAIVANA_PID=$!
- if ! wait_for_unix_socket "$PAIVANA_SOCK";
+ if ! wait_for_unix_socket "$PAIVANA_SOCK" "$PAIVANA_PID";
then
echo "FAIL: paivana-httpd did not create $PAIVANA_SOCK" >&2
tail -n 20 "$log" >&2
@@ -259,8 +343,9 @@ function start_upstreams() {
then
local log="$LOGDIR/py.log"
( exec python3 "$SRCDIR/upstream_py.py" "$PY_PORT" ) >"$log" 2>&1 &
- PIDS+=("$!")
- if ! wait_for_port 127.0.0.1 "$PY_PORT";
+ local pypid=$!
+ PIDS+=("$pypid")
+ if ! wait_for_port 127.0.0.1 "$PY_PORT" "$pypid";
then
echo "FAIL: upstream_py did not start on port $PY_PORT" >&2
tail -n 20 "$log" >&2
@@ -283,13 +368,13 @@ function test_get() {
local desc="$1" path="$2" want_status="$3" want_sub="$4"
msg "$desc"
local out status
- out="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$TMPDIR/err")" \
- || { fail "curl: $(cat "$TMPDIR/err")"; }
+ out="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$SCRATCH/err")" \
+ || { fail "curl: $(cat "$SCRATCH/err")"; }
status="$out"
[ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
- if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$TMPDIR/body";
+ if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$SCRATCH/body";
then
- fail "body missing substring '$want_sub' (got: $(tr -d '\n' <"$TMPDIR/body" | head -c 120))"
+ fail "body missing substring '$want_sub' (got: $(tr -d '\n' <"$SCRATCH/body" | head -c 120))"
fi
ok
}
@@ -299,8 +384,8 @@ function test_head() {
local desc="$1" path="$2" want_status="$3"
msg "$desc"
local status
- status="$(curl -sS -I -o /dev/null -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ status="$(curl -sS -I -o /dev/null -w '%{http_code}' "$(PAIVANA_URL "$path")" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "$want_status" ] || fail "status=$status want=$want_status"
ok
}
@@ -309,19 +394,19 @@ function test_head() {
function test_method() {
local desc="$1" method="$2" path="$3" body="$4" want_status="$5" want_sub="$6"
msg "$desc"
- local args=(-sS -o "$TMPDIR/body" -w '%{http_code}' -X "$method" "$(PAIVANA_URL "$path")")
+ local args=(-sS -o "$SCRATCH/body" -w '%{http_code}' -X "$method" "$(PAIVANA_URL "$path")")
if [ -n "$body" ];
then
args+=(--data-binary "@$body")
fi
local status
- status="$(curl "${args[@]}" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ status="$(curl "${args[@]}" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "$want_status" ] || \
- fail "status=$status want=$want_status; body=$(head -c 200 "$TMPDIR/body")"
- if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$TMPDIR/body";
+ fail "status=$status want=$want_status; body=$(head -c 200 "$SCRATCH/body")"
+ if [ -n "$want_sub" ] && ! grep -q -- "$want_sub" "$SCRATCH/body";
then
- fail "body missing substring '$want_sub' (got: $(head -c 200 "$TMPDIR/body"))"
+ fail "body missing substring '$want_sub' (got: $(head -c 200 "$SCRATCH/body"))"
fi
ok
}
@@ -352,55 +437,55 @@ function run_battery() {
test_get "[$label] GET /large/131072 (128 KiB response)" \
/large/131072 200 ""
local sz
- sz="$(wc -c <"$TMPDIR/body" | tr -d ' ')"
+ sz="$(wc -c <"$SCRATCH/body" | tr -d ' ')"
msg "[$label] verify 128 KiB body length"
[ "$sz" = "131072" ] || fail "got $sz bytes, expected 131072"
ok
# POST /echo โ round-trip body
- printf 'hello-payload-%s' "$label" >"$TMPDIR/post_body"
+ printf 'hello-payload-%s' "$label" >"$SCRATCH/post_body"
test_method "[$label] POST /echo (body round-trip)" \
- POST /echo "$TMPDIR/post_body" 200 "hello-payload-$label"
+ POST /echo "$SCRATCH/post_body" 200 "hello-payload-$label"
# POST /upload โ byte count
- dd if=/dev/urandom of="$TMPDIR/rnd" bs=1024 count=64 status=none
+ dd if=/dev/urandom of="$SCRATCH/rnd" bs=1024 count=64 status=none
test_method "[$label] POST /upload (64 KiB binary upload)" \
- POST /upload "$TMPDIR/rnd" 200 "Received 65536 bytes"
+ POST /upload "$SCRATCH/rnd" 200 "Received 65536 bytes"
# PUT /put
test_method "[$label] PUT /put (PUT forwarding)" \
- PUT /put "$TMPDIR/post_body" 200 "PUT received"
+ PUT /put "$SCRATCH/post_body" 200 "PUT received"
# PATCH /patch
test_method "[$label] PATCH /patch (PATCH forwarding)" \
- PATCH /patch "$TMPDIR/post_body" 200 "PATCH received"
+ PATCH /patch "$SCRATCH/post_body" 200 "PATCH received"
# DELETE /item/1 with empty body
msg "[$label] DELETE /item/1 (204 No Content)"
local status
- status="$(curl -sS -X DELETE -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /item/1)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ status="$(curl -sS -X DELETE -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /item/1)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "204" ] || fail "status=$status"
ok
# OPTIONS โ server should echo 204 + Allow
msg "[$label] OPTIONS /anything (204 + Allow header)"
local opts
- opts="$(curl -sS -X OPTIONS -D "$TMPDIR/hdrs" -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ opts="$(curl -sS -X OPTIONS -D "$SCRATCH/hdrs" -o /dev/null -w '%{http_code}' "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$opts" = "204" ] || fail "status=$opts"
- grep -qi '^allow:' "$TMPDIR/hdrs" || fail "no Allow header returned"
+ grep -qi '^allow:' "$SCRATCH/hdrs" || fail "no Allow header returned"
ok
# Header propagation: X-Forwarded-For must be added by paivana.
msg "[$label] GET /echo-headers (X-Forwarded-For added)"
- curl -sS -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- grep -qi '^x-forwarded-for:' "$TMPDIR/body" || \
- fail "upstream did not see X-Forwarded-For; headers:\n$(cat "$TMPDIR/body")"
- grep -qi '^x-forwarded-proto:' "$TMPDIR/body" || \
+ curl -sS -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ grep -qi '^x-forwarded-for:' "$SCRATCH/body" || \
+ fail "upstream did not see X-Forwarded-For; headers:\n$(cat "$SCRATCH/body")"
+ grep -qi '^x-forwarded-proto:' "$SCRATCH/body" || \
fail "upstream did not see X-Forwarded-Proto"
- grep -qi '^via:' "$TMPDIR/body" || \
+ grep -qi '^via:' "$SCRATCH/body" || \
fail "upstream did not see Via: paivana"
ok
@@ -412,10 +497,10 @@ function run_battery() {
local want_host stripped seen_host
stripped="${PAIVANA_DEST#*://}" # drop scheme
want_host="${stripped%%/*}" # drop any path
- seen_host="$(grep -i '^host:' "$TMPDIR/body" | tr -d '\r' | \
+ seen_host="$(grep -i '^host:' "$SCRATCH/body" | tr -d '\r' | \
sed -e 's/^[Hh][Oo][Ss][Tt]: *//')"
[ -n "$seen_host" ] || \
- fail "upstream saw no Host header; headers:\n$(cat "$TMPDIR/body")"
+ fail "upstream saw no Host header; headers:\n$(cat "$SCRATCH/body")"
[ "$seen_host" = "$want_host" ] || \
fail "upstream saw 'Host: $seen_host', want 'Host: $want_host'"
ok
@@ -424,10 +509,10 @@ function run_battery() {
# pseudonym *appended* to it, not replaced.
msg "[$label] client Via is preserved and paivana is appended"
curl -sS -H 'Via: 1.1 alpha.example, 2.0 beta.example' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local via
- via="$(grep -i '^via:' "$TMPDIR/body" | tr -d '\r')"
+ via="$(grep -i '^via:' "$SCRATCH/body" | tr -d '\r')"
[ -n "$via" ] || fail "no Via header at upstream"
# Expect: "Via: 1.1 alpha.example, 2.0 beta.example, 1.1 paivana"
case "$via" in
@@ -444,26 +529,26 @@ function run_battery() {
-H 'X-Custom-Hop: must-not-forward' \
-H 'X-Other-Hop: neither' \
-H 'X-Keep: keep-this' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- if grep -qi '^x-custom-hop:' "$TMPDIR/body";
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ if grep -qi '^x-custom-hop:' "$SCRATCH/body";
then
fail "X-Custom-Hop leaked to upstream (Connection list ignored)"
fi
- if grep -qi '^x-other-hop:' "$TMPDIR/body";
+ if grep -qi '^x-other-hop:' "$SCRATCH/body";
then
fail "X-Other-Hop leaked to upstream (Connection list ignored)"
fi
- grep -qi '^x-keep:.*keep-this' "$TMPDIR/body" || \
+ grep -qi '^x-keep:.*keep-this' "$SCRATCH/body" || \
fail "X-Keep (not named in Connection) was incorrectly dropped"
ok
# Custom request header must be forwarded.
msg "[$label] custom request header X-Test is forwarded"
curl -sS -H 'X-Test: dingbat-42' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- grep -qi '^x-test:.*dingbat-42' "$TMPDIR/body" || \
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ grep -qi '^x-test:.*dingbat-42' "$SCRATCH/body" || \
fail "upstream did not see X-Test: dingbat-42"
ok
@@ -475,15 +560,15 @@ function run_battery() {
msg "[$label] Paivana-Cookie is stripped from the forwarded Cookie"
curl -sS \
-H 'Cookie: sid=alpha;paivana-cookie=1234-secret; theme=dark' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- if grep -qi '^cookie:.*paivana-cookie' "$TMPDIR/body";
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ if grep -qi '^cookie:.*paivana-cookie' "$SCRATCH/body";
then
- fail "access cookie leaked upstream: $(grep -i '^cookie:' "$TMPDIR/body")"
+ fail "access cookie leaked upstream: $(grep -i '^cookie:' "$SCRATCH/body")"
fi
- grep -qi '^cookie:.*sid=alpha' "$TMPDIR/body" || \
+ grep -qi '^cookie:.*sid=alpha' "$SCRATCH/body" || \
fail "client cookie sid=alpha was dropped"
- grep -qi '^cookie:.*theme=dark' "$TMPDIR/body" || \
+ grep -qi '^cookie:.*theme=dark' "$SCRATCH/body" || \
fail "client cookie theme=dark was dropped"
ok
@@ -491,11 +576,11 @@ function run_battery() {
# should reach the origin -- not an empty one.
msg "[$label] lone Paivana-Cookie leaves no Cookie header"
curl -sS -H 'Cookie: Paivana-Cookie=1234-secret' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- if grep -qi '^cookie:' "$TMPDIR/body";
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ if grep -qi '^cookie:' "$SCRATCH/body";
then
- fail "unexpected Cookie header upstream: $(grep -i '^cookie:' "$TMPDIR/body")"
+ fail "unexpected Cookie header upstream: $(grep -i '^cookie:' "$SCRATCH/body")"
fi
ok
@@ -503,11 +588,11 @@ function run_battery() {
msg "[$label] cookies named like ours are not over-stripped"
curl -sS \
-H 'Cookie: Paivana-Cookie-2=keep; XPaivana-Cookie=keep2' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- grep -qi '^cookie:.*paivana-cookie-2=keep' "$TMPDIR/body" || \
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ grep -qi '^cookie:.*paivana-cookie-2=keep' "$SCRATCH/body" || \
fail "Paivana-Cookie-2 was incorrectly stripped"
- grep -qi '^cookie:.*xpaivana-cookie=keep2' "$TMPDIR/body" || \
+ grep -qi '^cookie:.*xpaivana-cookie=keep2' "$SCRATCH/body" || \
fail "XPaivana-Cookie was incorrectly stripped"
ok
@@ -518,26 +603,26 @@ function run_battery() {
# also pins that the filter is applied to the complete header
# block rather than as the headers stream in.
msg "[$label] headers named in upstream Connection: are stripped"
- curl -sS -D "$TMPDIR/hdrs" -o /dev/null \
- "$(PAIVANA_URL /conn-response)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- if grep -qi '^x-hop-before:' "$TMPDIR/hdrs";
+ curl -sS -D "$SCRATCH/hdrs" -o /dev/null \
+ "$(PAIVANA_URL /conn-response)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ if grep -qi '^x-hop-before:' "$SCRATCH/hdrs";
then
fail "X-Hop-Before leaked to client (upstream Connection ignored)"
fi
- if grep -qi '^x-hop-after:' "$TMPDIR/hdrs";
+ if grep -qi '^x-hop-after:' "$SCRATCH/hdrs";
then
fail "X-Hop-After leaked to client (upstream Connection ignored)"
fi
- grep -qi '^x-keep-resp:.*survivor' "$TMPDIR/hdrs" || \
+ grep -qi '^x-keep-resp:.*survivor' "$SCRATCH/hdrs" || \
fail "X-Keep-Resp (not named in Connection) was incorrectly dropped"
ok
# Response header passthrough: upstream sets X-Upstream.
msg "[$label] upstream response header X-Upstream is forwarded"
- curl -sS -D "$TMPDIR/hdrs" -o /dev/null "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- grep -qi '^x-upstream:' "$TMPDIR/hdrs" || \
+ curl -sS -D "$SCRATCH/hdrs" -o /dev/null "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ grep -qi '^x-upstream:' "$SCRATCH/hdrs" || \
fail "X-Upstream header not forwarded back to client"
ok
}
@@ -550,20 +635,20 @@ function test_method_not_allowed() {
msg "unsupported HTTP method (TRACE) yields 405"
local status
status="$(curl -sS -X TRACE -o /dev/null -w '%{http_code}' \
- "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "405" ] || fail "status=$status want=405"
ok
}
function test_upload_too_big() {
msg "upload exceeding 1 MiB buffer yields 413"
- dd if=/dev/zero of="$TMPDIR/big" bs=1024 count=2048 status=none
+ dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
local status
- status="$(curl -sS -X POST --data-binary "@$TMPDIR/big" \
+ status="$(curl -sS -X POST --data-binary "@$SCRATCH/big" \
-o /dev/null -w '%{http_code}' \
- "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
# 413 == Content Too Large; some builds report 500 on hook close
[ "$status" = "413" ] || fail "status=$status want=413"
ok
@@ -583,8 +668,8 @@ function test_upload_too_big_early() {
out="$( ( exec 3<>"/dev/tcp/127.0.0.1/$PAIVANA_PORT"
printf 'POST /upload HTTP/1.1\r\nHost: 127.0.0.1:%s\r\nContent-Length: 10485760\r\nConnection: close\r\n\r\n' \
"$PAIVANA_PORT" >&3
- timeout 5 cat <&3 ) 2>"$TMPDIR/err")" \
- || fail "raw POST failed (timeout or socket error): $(cat "$TMPDIR/err")"
+ timeout 5 cat <&3 ) 2>"$SCRATCH/err")" \
+ || fail "raw POST failed (timeout or socket error): $(cat "$SCRATCH/err")"
case "$out" in
'HTTP/1.1 413'*) ;;
*) fail "expected 413 status line, got: $(echo "$out" | head -c 80)";;
@@ -599,18 +684,18 @@ function test_upload_too_big_no_continue() {
# `Expect: 100-continue` and verbose tracing, then assert that
# no `< HTTP/1.1 100` line appeared on the wire.
msg "rejection suppresses 100 Continue when client opts in"
- dd if=/dev/zero of="$TMPDIR/big" bs=1024 count=2048 status=none
+ dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
local status
status="$(curl -sSv -X POST -H 'Expect: 100-continue' \
--expect100-timeout 5 \
- --data-binary "@$TMPDIR/big" \
+ --data-binary "@$SCRATCH/big" \
-o /dev/null -w '%{http_code}' \
- "$(PAIVANA_URL /upload)" 2>"$TMPDIR/trace")" \
- || fail "curl: $(cat "$TMPDIR/trace")"
+ "$(PAIVANA_URL /upload)" 2>"$SCRATCH/trace")" \
+ || fail "curl: $(cat "$SCRATCH/trace")"
[ "$status" = "413" ] || fail "status=$status want=413"
- if grep -q '^< HTTP/1.1 100' "$TMPDIR/trace";
+ if grep -q '^< HTTP/1.1 100' "$SCRATCH/trace";
then
- fail "server sent 100 Continue before 413; trace: $(grep '^<' "$TMPDIR/trace")"
+ fail "server sent 100 Continue before 413; trace: $(grep '^<' "$SCRATCH/trace")"
fi
ok
}
@@ -622,15 +707,15 @@ function test_upload_too_big_chunked() {
# drain-then-reject path that runs in BODY_RECEIVING /
# FULL_REQ_RECEIVED.
msg "chunked upload exceeding 1 MiB still yields 413 (drain path)"
- dd if=/dev/zero of="$TMPDIR/big" bs=1024 count=2048 status=none
+ dd if=/dev/zero of="$SCRATCH/big" bs=1024 count=2048 status=none
local status
status="$(curl -sS -X POST \
-H 'Transfer-Encoding: chunked' \
-H 'Content-Length:' \
- --data-binary "@$TMPDIR/big" \
+ --data-binary "@$SCRATCH/big" \
-o /dev/null -w '%{http_code}' \
- "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "413" ] || fail "status=$status want=413"
ok
}
@@ -640,9 +725,9 @@ function test_upload_too_big_chunked() {
# to have answered while paivana's curl is still uploading.
EARLY_PAYLOAD_SIZE=786432
-# Write the early-response payload to $TMPDIR/early_body, once.
+# Write the early-response payload to $SCRATCH/early_body, once.
function make_early_payload() {
- local payload="$TMPDIR/early_body"
+ local payload="$SCRATCH/early_body"
local got
[ -s "$payload" ] && return 0
dd if=/dev/urandom of="$payload" bs=1024 count=768 status=none
@@ -662,7 +747,7 @@ function start_early_upstream() {
>"$LOGDIR/early-$port.log" 2>&1 &
EARLY_PID=$!
PIDS+=("$EARLY_PID")
- if ! wait_for_port 127.0.0.1 "$port";
+ if ! wait_for_port 127.0.0.1 "$port" "$EARLY_PID";
then
fail "early_response_upstream did not start on port $port"
fi
@@ -674,9 +759,17 @@ function stop_early_upstream() {
kill -TERM "$EARLY_PID" 2>/dev/null
# In --no-drain mode the upstream may be parked on a
# connection whose peer never hangs up, so back the TERM with
- # a KILL: a wedged helper must not wedge the whole suite.
+ # a KILL: a wedged helper must not wedge the whole suite. The
+ # watchdog has to be cancelled once the wait returns -- which
+ # is the normal case -- or two seconds later it delivers a
+ # SIGKILL to whatever process has inherited that pid by then,
+ # quite possibly one of this suite's own later helpers, failing
+ # a test downstream with no visible cause.
( sleep 2; kill -KILL "$EARLY_PID" 2>/dev/null ) &
+ local watchdog=$!
wait "$EARLY_PID" 2>/dev/null
+ kill -TERM "$watchdog" 2>/dev/null
+ wait "$watchdog" 2>/dev/null
EARLY_PID=""
fi
}
@@ -717,7 +810,7 @@ function test_early_response() {
return
fi
stop_paivana
- local receipt="$TMPDIR/early_receipt"
+ local receipt="$SCRATCH/early_receipt"
start_early_upstream "$EARLY_PORT" "$receipt"
start_paivana "http://127.0.0.1:$EARLY_PORT"
make_early_payload
@@ -725,15 +818,15 @@ function test_early_response() {
# Without the early-response fix paivana would abort the curl
# handle and answer 502 instead of relaying what it was handed.
local status
- status="$(curl -sS -X POST --data-binary "@$TMPDIR/early_body" \
+ status="$(curl -sS -X POST --data-binary "@$SCRATCH/early_body" \
--max-time 30 \
- -o "$TMPDIR/body" -w '%{http_code}' \
- "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" -w '%{http_code}' \
+ "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "413" ] || \
fail "status=$status want=413 (a 502 here means paivana aborted the forward instead of relaying the early response)"
- grep -q 'early-response-payload' "$TMPDIR/body" || \
- fail "response body did not come from upstream: $(head -c 200 "$TMPDIR/body")"
+ grep -q 'early-response-payload' "$SCRATCH/body" || \
+ fail "response body did not come from upstream: $(head -c 200 "$SCRATCH/body")"
[ -n "$(read_receipt "$receipt")" ] || \
fail "upstream never finished the exchange (still blocked reading the body?)"
@@ -765,7 +858,7 @@ function test_early_response_no_drain() {
return
fi
stop_paivana
- local receipt="$TMPDIR/nodrain_receipt"
+ local receipt="$SCRATCH/nodrain_receipt"
start_early_upstream "$NODRAIN_PORT" "$receipt" --no-drain
start_paivana "http://127.0.0.1:$NODRAIN_PORT"
make_early_payload
@@ -775,17 +868,17 @@ function test_early_response_no_drain() {
# deadlock shows up as a killed curl rather than a late answer.
local status rc
status="$(timeout 20 curl -sS -X POST \
- --data-binary "@$TMPDIR/early_body" \
- -o "$TMPDIR/body" -w '%{http_code}' \
- "$(PAIVANA_URL /upload)" 2>"$TMPDIR/err")"
+ --data-binary "@$SCRATCH/early_body" \
+ -o "$SCRATCH/body" -w '%{http_code}' \
+ "$(PAIVANA_URL /upload)" 2>"$SCRATCH/err")"
rc=$?
[ "$rc" != "124" ] && [ "$rc" != "137" ] || \
fail "no response within 20s: paivana blocked on an upload the upstream stopped reading"
- [ "$rc" = "0" ] || fail "curl exited $rc: $(cat "$TMPDIR/err")"
+ [ "$rc" = "0" ] || fail "curl exited $rc: $(cat "$SCRATCH/err")"
[ "$status" = "413" ] || \
fail "status=$status want=413 (a 502 means paivana gave up on the transfer instead of using the response it had)"
- grep -q 'early-response-payload' "$TMPDIR/body" || \
- fail "response body did not come from upstream: $(head -c 200 "$TMPDIR/body")"
+ grep -q 'early-response-payload' "$SCRATCH/body" || \
+ fail "response body did not come from upstream: $(head -c 200 "$SCRATCH/body")"
# No receipt assertion here: the upstream only ever saw whatever
# fit in its receive buffer, and whether the connection is closed
# or kept for reuse afterwards is paivana's business. The count
@@ -805,7 +898,7 @@ function start_truncating_upstream() {
# fails the transfer, but the status line it parsed long before is
# still what CURLINFO_RESPONSE_CODE reports.
local port="$1"
- cat >"$TMPDIR/truncating_upstream.py" <<'PYEOF'
+ cat >"$SCRATCH/truncating_upstream.py" <<'PYEOF'
import socket
import sys
@@ -828,9 +921,10 @@ while True:
conn.close()
PYEOF
local log="$LOGDIR/truncating.log"
- ( exec python3 "$TMPDIR/truncating_upstream.py" "$port" ) >"$log" 2>&1 &
- PIDS+=("$!")
- if ! wait_for_port 127.0.0.1 "$port";
+ ( exec python3 "$SCRATCH/truncating_upstream.py" "$port" ) >"$log" 2>&1 &
+ local tpid=$!
+ PIDS+=("$tpid")
+ if ! wait_for_port 127.0.0.1 "$port" "$tpid";
then
echo "FAIL: truncating upstream did not start on port $port" >&2
tail -n 20 "$log" >&2
@@ -854,13 +948,13 @@ function test_short_body() {
start_truncating_upstream "$TRUNC_PORT"
start_paivana "http://127.0.0.1:$TRUNC_PORT"
local status
- status="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' \
+ status="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' \
--max-time 30 \
- "$(PAIVANA_URL /short)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /short)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "502" ] || \
fail "status=$status want=502 (a 200 here means the partial body was served as if complete)"
- grep -qi 'bad gateway' "$TMPDIR/body" || \
+ grep -qi 'bad gateway' "$SCRATCH/body" || \
fail "no 'Bad Gateway' in body"
ok
stop_paivana
@@ -873,12 +967,12 @@ function test_upstream_down() {
stop_paivana
start_paivana "http://127.0.0.1:$DEAD_PORT"
local status
- status="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' \
+ status="$(curl -sS -o "$SCRATCH/body" -w '%{http_code}' \
--max-time 10 \
- "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
[ "$status" = "502" ] || fail "status=$status want=502"
- grep -qi 'bad gateway' "$TMPDIR/body" || \
+ grep -qi 'bad gateway' "$SCRATCH/body" || \
fail "no 'Bad Gateway' in body"
ok
}
@@ -893,8 +987,8 @@ function test_keepalive_curl() {
-w '\n@status=%{http_code}\n' \
"$(PAIVANA_URL /hello)" \
"$(PAIVANA_URL /hello)" \
- "$(PAIVANA_URL /hello)" 2>"$TMPDIR/err")" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ "$(PAIVANA_URL /hello)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local count
count="$(printf '%s\n' "$out" | grep -c '^Hello from')"
[ "$count" = "3" ] || fail "got $count Hello lines; want 3; out:\n$out"
@@ -923,20 +1017,20 @@ function test_pipelined() {
fi
local out
out="$("$PIPELINE_CLIENT" 127.0.0.1 "$PAIVANA_PORT" \
- /hello /status/201 /hello /status/404 2>"$TMPDIR/err")" \
- || fail "pipeline_client: $(cat "$TMPDIR/err")"
- printf '%s\n' "$out" >"$TMPDIR/pipeline.out"
+ /hello /status/201 /hello /status/404 2>"$SCRATCH/err")" \
+ || fail "pipeline_client: $(cat "$SCRATCH/err")"
+ printf '%s\n' "$out" >"$SCRATCH/pipeline.out"
local n
- n="$(grep -c '^--- response' "$TMPDIR/pipeline.out")"
+ n="$(grep -c '^--- response' "$SCRATCH/pipeline.out")"
[ "$n" = "4" ] || fail "got $n responses, want 4; output:\n$out"
# Order preserved: responses must match the request sequence.
- grep -q '^--- response 0: status=200' "$TMPDIR/pipeline.out" \
+ grep -q '^--- response 0: status=200' "$SCRATCH/pipeline.out" \
|| fail "response 0: wrong status; out:\n$out"
- grep -q '^--- response 1: status=201' "$TMPDIR/pipeline.out" \
+ grep -q '^--- response 1: status=201' "$SCRATCH/pipeline.out" \
|| fail "response 1: wrong status; out:\n$out"
- grep -q '^--- response 2: status=200' "$TMPDIR/pipeline.out" \
+ grep -q '^--- response 2: status=200' "$SCRATCH/pipeline.out" \
|| fail "response 2: wrong status; out:\n$out"
- grep -q '^--- response 3: status=404' "$TMPDIR/pipeline.out" \
+ grep -q '^--- response 3: status=404' "$SCRATCH/pipeline.out" \
|| fail "response 3: wrong status; out:\n$out"
ok
}
@@ -955,7 +1049,7 @@ function test_pipelined() {
# Echo the upstream's view of one header. $1 = header name.
function upstream_header() {
- grep -i "^$1:" "$TMPDIR/body" | tr -d '\r' | sed -e "s/^[^:]*: *//"
+ grep -i "^$1:" "$SCRATCH/body" | tr -d '\r' | sed -e "s/^[^:]*: *//"
}
function test_forwarded_no_flag() {
@@ -963,8 +1057,8 @@ function test_forwarded_no_flag() {
curl -sS -H 'X-Forwarded-For: 1.2.3.4' \
-H 'X-Forwarded-Proto: https' \
-H 'X-Forwarded-Host: evil.example.com' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local xff proto host
xff="$(upstream_header x-forwarded-for)"
proto="$(upstream_header x-forwarded-proto)"
@@ -987,8 +1081,8 @@ function test_forwarded_with_flag() {
stop_paivana
start_paivana "$PAIVANA_DEST" -f
curl -sS -H 'X-Forwarded-For: 203.0.113.7, 198.51.100.9' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local xff
xff="$(upstream_header x-forwarded-for)"
# Our own peer is appended to the right of the chain we were given.
@@ -999,8 +1093,8 @@ function test_forwarded_with_flag() {
msg "-f: trusted X-Forwarded-Proto / -Host are passed through"
curl -sS -H 'X-Forwarded-Proto: https' \
-H 'X-Forwarded-Host: public.example.com' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local proto host
proto="$(upstream_header x-forwarded-proto)"
host="$(upstream_header x-forwarded-host)"
@@ -1011,8 +1105,8 @@ function test_forwarded_with_flag() {
ok
msg "-f: no inbound chain still yields our own peer"
- curl -sS -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ curl -sS -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
xff="$(upstream_header x-forwarded-for)"
[ "$xff" = "127.0.0.1" ] || \
fail "X-Forwarded-For='$xff', want '127.0.0.1'"
@@ -1023,10 +1117,10 @@ function test_forwarded_with_flag() {
# one comma-joined line, and must reach the origin as one header.
curl -sS -H 'X-Forwarded-For: 203.0.113.7' \
-H 'X-Forwarded-For: 198.51.100.9' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local n
- n="$(grep -ci '^x-forwarded-for:' "$TMPDIR/body")"
+ n="$(grep -ci '^x-forwarded-for:' "$SCRATCH/body")"
[ "$n" = "1" ] || fail "upstream saw $n X-Forwarded-For headers, want 1"
xff="$(upstream_header x-forwarded-for)"
[ "$xff" = "203.0.113.7, 198.51.100.9, 127.0.0.1" ] || \
@@ -1049,8 +1143,8 @@ function test_forwarded_unix() {
start_paivana_unix "$dest" -f
curl -sS --unix-socket "$PAIVANA_SOCK" \
-H 'X-Forwarded-For: 203.0.113.7' \
- -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local xff
xff="$(upstream_header x-forwarded-for)"
# Nothing is appended: a Unix peer has no address, and inventing
@@ -1058,8 +1152,8 @@ function test_forwarded_unix() {
# loopback client. The hop is recorded in Via instead.
[ "$xff" = "203.0.113.7" ] || \
fail "X-Forwarded-For='$xff', want '203.0.113.7' (unadorned)"
- grep -qi '^via:.*paivana' "$TMPDIR/body" || \
- fail "Via does not record the paivana hop; headers:\n$(cat "$TMPDIR/body")"
+ grep -qi '^via:.*paivana' "$SCRATCH/body" || \
+ fail "Via does not record the paivana hop; headers:\n$(cat "$SCRATCH/body")"
ok
msg "unix socket, no -f: no X-Forwarded-For is invented"
@@ -1067,11 +1161,11 @@ function test_forwarded_unix() {
start_paivana_unix "$dest"
curl -sS --unix-socket "$PAIVANA_SOCK" \
-H 'X-Forwarded-For: 1.2.3.4' \
- -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
- grep -qi '^x-forwarded-for:' "$TMPDIR/body" && \
- fail "upstream saw an X-Forwarded-For we cannot substantiate:\n$(cat "$TMPDIR/body")"
- grep -qi '^via:.*paivana' "$TMPDIR/body" || \
+ -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ grep -qi '^x-forwarded-for:' "$SCRATCH/body" && \
+ fail "upstream saw an X-Forwarded-For we cannot substantiate:\n$(cat "$SCRATCH/body")"
+ grep -qi '^via:.*paivana' "$SCRATCH/body" || \
fail "Via does not record the paivana hop"
ok
@@ -1085,8 +1179,8 @@ function test_forwarded_rfc7239() {
msg "-f: RFC 7239 Forwarded is extended with our own element"
curl -sS -H 'Forwarded: for=203.0.113.7;proto=https;host=public.example.com' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local fwd
fwd="$(upstream_header forwarded)"
case "$fwd" in
@@ -1116,8 +1210,8 @@ function test_forwarded_rfc7239() {
# synthesized rather than one with a hop silently missing.
msg "-f: a Forwarded chain that X-Forwarded-For cannot express is not faked"
curl -sS -H 'Forwarded: for=unknown' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
xff="$(upstream_header x-forwarded-for)"
[ "$xff" = "127.0.0.1" ] || \
fail "X-Forwarded-For='$xff', want just our own peer '127.0.0.1'"
@@ -1132,8 +1226,8 @@ function test_forwarded_rfc7239() {
stop_paivana
start_paivana "$PAIVANA_DEST"
curl -sS -H 'Forwarded: for=1.2.3.4;proto=https' \
- -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" "$(PAIVANA_URL /echo-headers)" 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
fwd="$(upstream_header forwarded)"
case "$fwd" in
*1.2.3.4*) fail "client's Forwarded element survived: '$fwd'";;
@@ -1154,8 +1248,8 @@ function test_forwarded_unix_rfc7239() {
start_paivana_unix "$dest" -f
curl -sS --unix-socket "$PAIVANA_SOCK" \
-H 'Forwarded: for=203.0.113.7' \
- -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \
- || fail "curl: $(cat "$TMPDIR/err")"
+ -o "$SCRATCH/body" http://localhost/echo-headers 2>"$SCRATCH/err" \
+ || fail "curl: $(cat "$SCRATCH/err")"
local fwd
fwd="$(upstream_header forwarded)"
case "$fwd" in
@@ -1186,17 +1280,24 @@ function test_forwarded_unix_rfc7239() {
# Start paivana with an extra config line and report whether it came
# up. Echoes "started" or "refused".
+#
+# "Refused" is read off wait_for_port giving up, so the pid has to be
+# passed: a refused config makes paivana exit in milliseconds, and
+# without the liveness check the verdict would come from a five-second
+# timeout instead -- and would be wrong outright if anything else were
+# holding the port, since the loop would then see a listener and call
+# every refusal an acceptance.
function paivana_with_config_line() {
local line="$1"
- local cfg="$TMPDIR/trusted.conf"
+ local cfg="$SCRATCH/startup.conf"
sed -e "s|@DEST@|http://127.0.0.1:$MHD_PORT|g" \
-e "s|@PORT@|$PAIVANA_PORT|g" \
"$SRCDIR/test_reverse_proxy.conf.in" > "$cfg"
printf '%s\n' "$line" >> "$cfg"
- local log="$LOGDIR/trusted.log"
+ local log="$LOGDIR/startup.log"
( exec "$PAIVANA_HTTPD" -c "$cfg" -n -f -L ERROR ) >"$log" 2>&1 &
local pid=$!
- if wait_for_port 127.0.0.1 "$PAIVANA_PORT";
+ if wait_for_port 127.0.0.1 "$PAIVANA_PORT" "$pid";
then
kill -TERM "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
@@ -1242,7 +1343,7 @@ function test_trusted_proxies_config() {
msg "startup accepted: $good"
r="$(paivana_with_config_line "$good")"
[ "$r" = "started" ] || \
- fail "paivana refused a usable policy ($good); log:\n$(cat "$LOGDIR/trusted.log")"
+ fail "paivana refused a usable policy ($good); log:\n$(cat "$LOGDIR/startup.log")"
ok
done
@@ -1250,14 +1351,111 @@ function test_trusted_proxies_config() {
}
######################################################################
+# WHITELIST configuration validation.
+#
+# WHITELIST names the paths served without payment, and paivana wraps
+# it in "^(%s)$" before regcomp: regexec(3) is unanchored, so a
+# WHITELIST of "/free/" would otherwise waive payment for every URL
+# merely *containing* it, and the group keeps an alternation from
+# binding the anchors to only its outer branches.
+#
+# What the suite can reach of this is the loading, not the matching:
+# regcomp happens at config time regardless of -n, while the regexec
+# sits behind the paywall that -n switches off, and paivana will not
+# start without -n unless a merchant backend is there to serve it
+# templates. So these cases pin that an unusable expression is
+# refused rather than carried into the process -- the alternative
+# being a paivana that runs with an uninitialised regex_t.
+######################################################################
+
+function test_whitelist_config() {
+ stop_paivana
+ local r
+
+ for bad in \
+ 'WHITELIST = *invalid(' \
+ 'WHITELIST = /free/[' \
+ 'WHITELIST = /free/\'
+ do
+ msg "startup refused: $bad"
+ r="$(paivana_with_config_line "$bad")"
+ [ "$r" = "refused" ] || \
+ fail "paivana started with an uncompilable WHITELIST ($bad)"
+ ok
+ done
+
+ for good in \
+ 'WHITELIST = /free/.*' \
+ 'WHITELIST = /free/.*|/assets/.*' \
+ 'WHITELIST = ^/free/.*$'
+ do
+ msg "startup accepted: $good"
+ r="$(paivana_with_config_line "$good")"
+ [ "$r" = "started" ] || \
+ fail "paivana refused a usable WHITELIST ($good); log:\n$(cat "$LOGDIR/startup.log")"
+ ok
+ done
+
+ start_paivana "http://127.0.0.1:$MHD_PORT"
+}
+
+######################################################################
+# The payment endpoint under -n.
+#
+# POST /.well-known/paivana is the one paywall-side branch that -n
+# does not shield: the handler answers 501 rather than falling through
+# to the proxy. Everything else about the endpoint is unreachable
+# here, but this much is worth pinning, because the two ways to get it
+# wrong are both silent. Forwarding the POST upstream would hand the
+# origin a request carrying payment data it has no business seeing;
+# claiming the path for every method would shadow whatever the origin
+# serves at that URL.
+######################################################################
+
+function test_paywall_disabled_endpoint() {
+ msg "-n: POST /.well-known/paivana is 501, not forwarded"
+ local status
+ status="$(curl -sS -D "$SCRATCH/hdrs" -o "$SCRATCH/body" -w '%{http_code}' \
+ -X POST -H 'Content-Type: application/json' \
+ --data '{}' \
+ "$(PAIVANA_URL /.well-known/paivana)" \
+ 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ [ "$status" = "501" ] || fail "status=$status want=501"
+ # An upstream that had seen the request would have labelled the
+ # response; paivana answering for itself does not.
+ if grep -qi '^x-upstream:' "$SCRATCH/hdrs";
+ then
+ fail "the POST reached the upstream"
+ fi
+ ok
+
+ # `is_paivana' is set only for POST, so the endpoint must not
+ # swallow the origin's own URL space at that path.
+ msg "-n: GET /.well-known/paivana is forwarded like any other path"
+ status="$(curl -sS -D "$SCRATCH/hdrs" -o "$SCRATCH/body" -w '%{http_code}' \
+ "$(PAIVANA_URL /.well-known/paivana)" 2>"$SCRATCH/err")" \
+ || fail "curl: $(cat "$SCRATCH/err")"
+ [ "$status" != "501" ] || fail "GET was answered by the paywall handler"
+ grep -qi '^x-upstream:' "$SCRATCH/hdrs" || \
+ fail "GET did not reach the upstream (status=$status)"
+ ok
+}
+
+######################################################################
# Drive the tests.
######################################################################
echo "=== paivana reverse-proxy tests ==="
-echo "Temp dir: $TMPDIR"
+echo "Temp dir: $SCRATCH"
echo "Paivana binary: $PAIVANA_HTTPD"
echo "Source dir: $SRCDIR"
echo "Build dir: $BUILDDIR"
+echo "Ports: $PORT_BASE + 1..7, 99, 100"
+
+require_ports_free "$MHD_PORT" "$GO_PORT" "$PY_PORT" "$RS_PORT" \
+ "$EARLY_PORT" "$NODRAIN_PORT" "$TRUNC_PORT" \
+ "$DEAD_PORT" "$PAIVANA_PORT"
start_upstreams
@@ -1278,7 +1476,9 @@ test_forwarded_with_flag
test_forwarded_unix
test_forwarded_rfc7239
test_forwarded_unix_rfc7239
+test_paywall_disabled_endpoint
test_trusted_proxies_config
+test_whitelist_config
stop_paivana
diff --git a/src/tests/upstream_rs.rs b/src/tests/upstream_rs.rs
@@ -96,7 +96,9 @@ fn send_response(stream: &mut TcpStream, code: u16, reason: &str,
for (k, v) in extra_headers {
head.push_str(&format!("{}: {}\r\n", k, v));
}
- head.push_str("Connection: keep-alive\r\n\r\n");
+ // See client_loop(): this server answers one request per
+ // connection and then drops the stream, so it has to say so.
+ head.push_str("Connection: close\r\n\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
}
@@ -115,7 +117,7 @@ fn handle(req: &Request, stream: &mut TcpStream) {
// For HEAD, still advertise correct Content-Length of the would-be body.
if req.method == "HEAD" {
let head = format!(
- "HTTP/1.1 200 OK\r\nX-Upstream: {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n",
+ "HTTP/1.1 200 OK\r\nX-Upstream: {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
UPSTREAM,
body.len()
);
@@ -202,8 +204,16 @@ fn handle(req: &Request, stream: &mut TcpStream) {
fn client_loop(mut stream: TcpStream) {
// We can't easily loop keep-alive with our BufReader pattern without
- // ownership gymnastics; handle one request per connection. paivana
- // opens fresh connections to us, so this is fine.
+ // ownership gymnastics; handle one request per connection. That is
+ // why every response above carries `Connection: close': paivana
+ // puts its outbound handles on a shared curl multi handle and sets
+ // neither CURLOPT_FORBID_REUSE nor CURLOPT_FRESH_CONNECT, so a
+ // connection we told it to keep is a connection it will reuse --
+ // and find shut. libcurl retries an idempotent request, which
+ // hides it, but a POST or PUT whose body is already partly on the
+ // wire is not retried: the transfer fails, paivana answers 502, and
+ // the suite reports an intermittent proxy bug that is really this
+ // server lying about its own connection handling.
if let Some(req) = parse_request(&mut stream) {
handle(&req, &mut stream);
}