paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

commit ec75766944169e42f22fd406dfcebc79038cbf61
parent b5162388d5ee344d126d07bd84f528459812e1e8
Author: Christian Grothoff <christian@grothoff.org>
Date:   Fri,  7 Aug 2026 16:43:33 +0200

expand cookie tests

Diffstat:
Msrc/tests/README | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/tests/meson.build | 25+++++++++++++++++++++++++
Asrc/tests/test_cookie_access.c | 708+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/test_reverse_proxy.sh | 582+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/tests/upstream_rs.rs | 18++++++++++++++----
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); }