paivana

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

commit cd419645f7a2644ddd613849d1c61d264ef30107
parent 92e1a4ebf55100e9b831ac621b3c9c4ae83511d6
Author: Florian Dold <dold@taler.net>
Date:   Wed, 26 Aug 2026 22:09:18 +0200

payment: route merchant calls through an internal URL

Keep the public merchant URL in browser-facing payment data while allowing Paivana to use a cleartext HTTP instance URL over a Unix socket. Add forced-fresh diagnostics and regression coverage for intermittent pooled-connection failures.

Diffstat:
MNEWS | 14+++++++++++++-
MREADME | 38++++++++++++++++++++++++++++++++++----
Mdebian/etc/paivana/paivana.conf | 5+++++
Msrc/backend/paivana-httpd.c | 39+++++++++++++++++++++++++++++++++++++++
Msrc/backend/paivana-httpd.h | 15++++++++++++++-
Msrc/backend/paivana-httpd_pay.c | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/backend/paivana-httpd_templates.c | 8++++----
Msrc/tests/README | 44++++++++++++++++++++++++++++++++------------
Asrc/tests/merchant_fault_proxy.py | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/payment_backend_stub.py | 7++++++-
Msrc/tests/test_payment_backend_failure.sh | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/tests/test_paywall.sh | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
12 files changed, 1033 insertions(+), 47 deletions(-)

diff --git a/NEWS b/NEWS @@ -4,7 +4,19 @@ Unreleased: within the original five-second deadline, preventing a stale pooled connection from intermittently turning a completed payment into error 9801. Merchant-related error JSON now includes `merchant_http_status', - with zero meaning that no HTTP response was received. + with zero meaning that no HTTP response was received. When both attempts + fail early, the sampled warning queues one bounded, forced-fresh request + for the same order and logs its addresses, HTTP result, OS errno, + connection count and DNS/TCP/TLS/first-byte timings. This distinguishes a + shared connection-pool failure from general backend reachability without + logging the bearer token or response body. + + - MERCHANT_BACKEND_INTERNAL_URL may now select a private HTTP(S) route for + Paivana's merchant API calls while MERCHANT_BACKEND_URL remains the public + URL advertised to browsers and wallets. In particular, a public HTTPS + merchant can be reached through a cleartext MERCHANT_BACKEND_UNIX_PATH + without attempting TLS over that socket or exposing an internal URL in the + paywall. - High-load operation is now fail-fast and bounded. CONNECTION_LIMIT defaults to 384 and may not exceed the select()-safe descriptor budget; diff --git a/README b/README @@ -97,6 +97,8 @@ Paivana reads an INI-style `.conf` file. The only section used is [paivana] DESTINATION_BASE_URL = https://example.com/ MERCHANT_BACKEND_URL = https://backend.demo.taler.net/instances/sandbox/ + # Optional server-side route; useful with MERCHANT_BACKEND_UNIX_PATH. + # MERCHANT_BACKEND_INTERNAL_URL = http://backend.internal/instances/sandbox/ MERCHANT_ACCESS_TOKEN = secret-token:sandbox BASE_URL = http://localhost:9967/ SERVE = tcp @@ -107,7 +109,10 @@ Paivana reads an INI-style `.conf` file. The only section used is Key Description ---------------------- ----------------------------------------------------- DESTINATION_BASE_URL Upstream server to proxy to once payment is confirmed. - MERCHANT_BACKEND_URL Base URL of the Taler merchant backend. + MERCHANT_BACKEND_URL Public base URL of the Taler merchant backend. + It is advertised to browsers and wallets as well + as used for backend requests unless an internal + URL is configured below. MERCHANT_ACCESS_TOKEN Bearer token for all calls to the merchant backend. BASE_URL Public base URL of Paivana. Required unless `-f` is given, in which case it is derived from the @@ -192,9 +197,21 @@ Paivana reads an INI-style `.conf` file. The only section used is URL is still what the request line and `Host` are built from, so it remains required. MERCHANT_BACKEND_UNIX_PATH - The same for the merchant backend, relative to - `MERCHANT_BACKEND_URL`. An unusable value is a warning and - the setting is then ignored, not a startup failure. + Unix-domain socket to reach the merchant backend. The URL + scheme of `MERCHANT_BACKEND_INTERNAL_URL` (or, when absent, + `MERCHANT_BACKEND_URL`) is still spoken over that socket: + use `http` for a cleartext socket and `https` only for a + TLS-speaking socket. An unusable path is a warning and the + setting is then ignored, not a startup failure. + MERCHANT_BACKEND_INTERNAL_URL + Complete merchant-instance base URL used only for Paivana's + server-side template and order requests. It must be an HTTP(S) + URL ending in `/` and include the instance path. Defaults to + `MERCHANT_BACKEND_URL`. Set this to an `http` URL together with + `MERCHANT_BACKEND_UNIX_PATH` when a public HTTPS merchant is + exposed locally through a cleartext socket; browser-facing + pay-template URIs, JavaScript and CSP continue to use the public + `MERCHANT_BACKEND_URL`. Running @@ -235,6 +252,19 @@ covers unpaywalled. Transient backend outages are the service manager's job to ride out -- the shipped `paivana-httpd.service` restarts with an increasing back-off, and does not restart on a configuration error. +An order-status request that fails before receiving any HTTP response is +retried once inside the original five-second deadline. If that retry also +fails early, Paivana's sampled warning is followed by one diagnostic GET for +the same order with a forced fresh connection. Its log record reports whether +an HTTP status was completed or merely observed before a transfer error, the +new-connection count, local and remote addresses, OS errno, HTTP version and +DNS/TCP/TLS/first-byte/total timings. The diagnostic uses the existing +authenticated merchant context but never logs its bearer token or response +body, and its result does not change the error returned to the client. A real +HTTP status from the forced-fresh request, after both shared-context attempts +failed, is strong evidence for a connection-pool/reuse problem rather than a +merchant application outage. + An instance that offers no template at all is refused for the same reason: with nothing to sell, no URL ever matches a paywall and the entire site would be served for free without a word of warning. Serving diff --git a/debian/etc/paivana/paivana.conf b/debian/etc/paivana/paivana.conf @@ -8,6 +8,11 @@ SERVE = systemd # start without them. # DESTINATION_BASE_URL = http://localhost:8080/ # MERCHANT_BACKEND_URL = http://localhost:9966/ +# If Paivana should use a different server-side route while advertising the +# public URL above, set both of these. The URL scheme is spoken over the +# socket, so use http:// for a cleartext Unix listener. +# MERCHANT_BACKEND_INTERNAL_URL = http://merchant.internal/instances/paivana/ +# MERCHANT_BACKEND_UNIX_PATH = /run/taler-merchant/merchant-http.sock # Which HTTP port does the backend listen on? Only used if "SERVE" is 'tcp'. # PORT = 9967 diff --git a/src/backend/paivana-httpd.c b/src/backend/paivana-httpd.c @@ -49,6 +49,10 @@ char *PH_target_server_unixpath; char *PH_merchant_base_url; +char *PH_merchant_internal_url; + +char *PH_merchant_unixpath; + char *PH_base_url; struct GNUNET_CURL_Context *PH_merchant_ctx; @@ -410,6 +414,8 @@ finish_shutdown (void) GNUNET_free (PH_trusted_proxies4); GNUNET_free (PH_trusted_proxies6); GNUNET_free (PH_merchant_base_url); + GNUNET_free (PH_merchant_internal_url); + GNUNET_free (PH_merchant_unixpath); GNUNET_free (PH_base_url); if (PH_have_whitelist_ex) { @@ -1002,6 +1008,34 @@ run (void *cls, GNUNET_SCHEDULER_shutdown (); return; } + if (GNUNET_OK != + GNUNET_CONFIGURATION_get_value_string ( + c, + "paivana", + "MERCHANT_BACKEND_INTERNAL_URL", + &PH_merchant_internal_url)) + PH_merchant_internal_url = GNUNET_strdup (PH_merchant_base_url); + if (! TALER_is_web_url (PH_merchant_internal_url)) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "paivana", + "MERCHANT_BACKEND_INTERNAL_URL", + "not a web url"); + PH_global_ret = EXIT_NOTCONFIGURED; + GNUNET_SCHEDULER_shutdown (); + return; + } + if ('/' != PH_merchant_internal_url[ + strlen (PH_merchant_internal_url) - 1]) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "paivana", + "MERCHANT_BACKEND_INTERNAL_URL", + "must end with a '/'"); + PH_global_ret = EXIT_NOTCONFIGURED; + GNUNET_SCHEDULER_shutdown (); + return; + } } { char *merchant_unix_path; @@ -1020,6 +1054,11 @@ run (void *cls, "MERCHANT_BACKEND_UNIX_PATH", "invalid path; ignoring the setting"); } + else + { + PH_merchant_unixpath = merchant_unix_path; + merchant_unix_path = NULL; + } GNUNET_free (merchant_unix_path); } } diff --git a/src/backend/paivana-httpd.h b/src/backend/paivana-httpd.h @@ -78,11 +78,24 @@ extern char *PH_target_server_base_url; extern char *PH_target_server_unixpath; /** - * Merchant backend base URL. + * Public merchant backend base URL advertised to browsers and wallets. */ extern char *PH_merchant_base_url; /** + * Merchant backend base URL used for Paivana's private API requests. + * Defaults to a copy of #PH_merchant_base_url. + */ +extern char *PH_merchant_internal_url; + +/** + * Optional Unix socket used to reach the merchant backend. Kept so the + * forced-fresh transport diagnostic follows the same route as the regular + * merchant client. + */ +extern char *PH_merchant_unixpath; + +/** * Base URL of this site as seen by the client. If not set, * we will try to determine it from "X-Forwarded-Host" and * "Host" and "X-Forwarded-Port" headers. diff --git a/src/backend/paivana-httpd_pay.c b/src/backend/paivana-httpd_pay.c @@ -24,7 +24,9 @@ * @brief payment processing logic */ #include "platform.h" +#include <curl/curl.h> #include <microhttpd.h> +#include <gnunet/gnunet_curl_lib.h> #include <gnunet/gnunet_util_lib.h> #include <taler/taler_mhd_lib.h> #include <taler/taler_json_lib.h> @@ -194,6 +196,27 @@ static unsigned int merchant_transport_failures; static unsigned int active_merchant_lookups; /** + * One forced-fresh request started after a sampled early transport failure. + * It is diagnostic only: the client's response is still determined by the + * two regular merchant API attempts. Keeping these in a DLL lets shutdown + * cancel both a not-yet-started scheduler task and a live curl job safely. + */ +struct MerchantTransportDiagnostic +{ + struct MerchantTransportDiagnostic *next; + struct MerchantTransportDiagnostic *prev; + struct GNUNET_SCHEDULER_Task *task; + struct GNUNET_CURL_Job *job; + CURL *easy; + char *url; + char *order_id; + char error[CURL_ERROR_SIZE]; +}; + +static struct MerchantTransportDiagnostic *diagnostic_head; +static struct MerchantTransportDiagnostic *diagnostic_tail; + +/** * Merchant transport diagnostics are useful immediately and then at most once * per minute per failure class. A minute is short enough for an operator to * see a persistent outage in routine monitoring, while reducing 32 concurrent @@ -235,6 +258,261 @@ static struct MerchantFailureLogState failure_logs[MFC_COUNT]; /** + * Discard a diagnostic response body without retaining contract data. + */ +static size_t +discard_diagnostic_body (void *cls, + const void *data, + size_t data_size) +{ + (void) cls; + (void) data; + return data_size; +} + + +/** + * Make curl's fixed-size error buffer safe to embed in one log record. + */ +static void +sanitize_diagnostic_error (char *error) +{ + for (char *p = error; '\0' != *p; p++) + if (iscntrl ((unsigned char) *p) || + ('"' == *p) || + ('\\' == *p)) + *p = ' '; +} + + +/** + * Release a completed diagnostic after GNUnet has removed and cleaned up its + * easy handle. In particular, CURLOPT_ERRORBUFFER requires @e md->error to + * remain alive until that cleanup is over, which happens after the completion + * callback returns. + */ +static void +free_completed_diagnostic (void *cls) +{ + struct MerchantTransportDiagnostic *md = cls; + + md->task = NULL; + GNUNET_CONTAINER_DLL_remove (diagnostic_head, + diagnostic_tail, + md); + GNUNET_free (md->url); + GNUNET_free (md->order_id); + GNUNET_free (md); +} + + +/** + * Report the result while the easy handle still exists, then release our + * closure on the next scheduler turn. GNUnet removes and destroys the curl + * job after this callback returns. + */ +static void +fresh_diagnostic_finished (void *cls, + long completed_http_status, + const void *body, + size_t body_size) +{ + struct MerchantTransportDiagnostic *md = cls; + long observed_http_status = 0; + long new_connections = -1; + long os_errno = 0; + long http_version = 0; + const char *remote_ip = NULL; + const char *local_ip = NULL; + double dns_s = 0; + double tcp_s = 0; + double tls_s = 0; + double first_byte_s = 0; + double total_s = 0; + + (void) body; + (void) body_size; + md->job = NULL; + (void) curl_easy_getinfo (md->easy, + CURLINFO_RESPONSE_CODE, + &observed_http_status); + (void) curl_easy_getinfo (md->easy, + CURLINFO_NUM_CONNECTS, + &new_connections); + (void) curl_easy_getinfo (md->easy, + CURLINFO_OS_ERRNO, + &os_errno); + (void) curl_easy_getinfo (md->easy, + CURLINFO_HTTP_VERSION, + &http_version); + (void) curl_easy_getinfo (md->easy, + CURLINFO_PRIMARY_IP, + &remote_ip); + (void) curl_easy_getinfo (md->easy, + CURLINFO_LOCAL_IP, + &local_ip); + (void) curl_easy_getinfo (md->easy, + CURLINFO_NAMELOOKUP_TIME, + &dns_s); + (void) curl_easy_getinfo (md->easy, + CURLINFO_CONNECT_TIME, + &tcp_s); + (void) curl_easy_getinfo (md->easy, + CURLINFO_APPCONNECT_TIME, + &tls_s); + (void) curl_easy_getinfo (md->easy, + CURLINFO_STARTTRANSFER_TIME, + &first_byte_s); + (void) curl_easy_getinfo (md->easy, + CURLINFO_TOTAL_TIME, + &total_s); + sanitize_diagnostic_error (md->error); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Forced-fresh merchant diagnostic for order `%s': completed" + " HTTP status %ld, observed HTTP status %ld, curl error" + " \"%s\", new connections %ld, local address %s, remote" + " address %s, OS errno %ld, HTTP version %ld; timings in" + " seconds (TCP Fast Open enabled): DNS %.6f, TCP %.6f, TLS" + " %.6f, first byte %.6f, total %.6f%s\n", + md->order_id, + completed_http_status, + observed_http_status, + md->error, + new_connections, + (NULL != local_ip) ? local_ip : "<none>", + (NULL != remote_ip) ? remote_ip : "<none>", + os_errno, + http_version, + dns_s, + tcp_s, + tls_s, + first_byte_s, + total_s, + (0 != completed_http_status) + ? "; a fresh connection reached the merchant after the shared" + " merchant context failed" + : ""); + md->easy = NULL; + md->task = GNUNET_SCHEDULER_add_now (&free_completed_diagnostic, + md); +} + + +/** + * Start a forced-fresh request after the failed merchant job has completely + * left libcurl's multi handle. This is the useful A/B comparison with the + * two regular attempts, which share the long-lived connection pool. + */ +static void +start_fresh_diagnostic (void *cls) +{ + struct MerchantTransportDiagnostic *md = cls; + struct GNUNET_CURL_StreamHandlers sh = { + .scb = &discard_diagnostic_body, + .scb_cls = md, + .jcc = &fresh_diagnostic_finished, + .jcc_cls = md + }; + CURLcode cc; + + md->task = NULL; + md->easy = curl_easy_init (); + if (NULL == md->easy) + goto fail; + md->error[0] = '\0'; +#define SET_DIAGNOSTIC_OPTION(opt,val) do { \ + cc = curl_easy_setopt (md->easy, opt, val); \ + if (CURLE_OK != cc) \ + goto setopt_fail; \ + } while (0) + SET_DIAGNOSTIC_OPTION (CURLOPT_URL, + md->url); + SET_DIAGNOSTIC_OPTION (CURLOPT_ERRORBUFFER, + md->error); + SET_DIAGNOSTIC_OPTION (CURLOPT_CONNECTTIMEOUT_MS, + 1500L); + SET_DIAGNOSTIC_OPTION (CURLOPT_TIMEOUT_MS, + 2000L); + SET_DIAGNOSTIC_OPTION (CURLOPT_ACCEPT_ENCODING, + ""); + SET_DIAGNOSTIC_OPTION (CURLOPT_TCP_FASTOPEN, + 1L); + SET_DIAGNOSTIC_OPTION (CURLOPT_FRESH_CONNECT, + 1L); + SET_DIAGNOSTIC_OPTION (CURLOPT_FORBID_REUSE, + 1L); + if (NULL != PH_merchant_unixpath) + SET_DIAGNOSTIC_OPTION (CURLOPT_UNIX_SOCKET_PATH, + PH_merchant_unixpath); +#undef SET_DIAGNOSTIC_OPTION + md->job = GNUNET_CURL_job_add_stream (PH_merchant_ctx, + md->easy, + NULL, + &sh); + if (NULL != md->job) + return; + md->easy = NULL; /* GNUNET_CURL_job_add_stream() released it. */ + goto fail; + +setopt_fail: +#undef SET_DIAGNOSTIC_OPTION + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Could not configure forced-fresh merchant diagnostic for" + " order `%s': %s\n", + md->order_id, + curl_easy_strerror (cc)); + curl_easy_cleanup (md->easy); + md->easy = NULL; +fail: + GNUNET_CONTAINER_DLL_remove (diagnostic_head, + diagnostic_tail, + md); + GNUNET_free (md->url); + GNUNET_free (md->order_id); + GNUNET_free (md); +} + + +/** + * Queue one credential-safe diagnostic of the exact failed order lookup. + * The bearer token is attached by #PH_merchant_ctx and is never copied here. + */ +static void +queue_fresh_diagnostic (const struct PayRequest *ph) +{ + struct MerchantTransportDiagnostic *md; + char *path; + char *paivana_id; + + GNUNET_asprintf (&path, + "private/orders/%s", + ph->order_id); + paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->expiration, + ph->website, + &ph->nonce); + md = GNUNET_new (struct MerchantTransportDiagnostic); + md->url = TALER_url_join (PH_merchant_internal_url, + path, + "session_id", + paivana_id, + NULL); + GNUNET_free (paivana_id); + GNUNET_free (path); + if (NULL == md->url) + { + GNUNET_free (md); + return; + } + md->order_id = GNUNET_strdup (ph->order_id); + GNUNET_CONTAINER_DLL_insert (diagnostic_head, + diagnostic_tail, + md); + md->task = GNUNET_SCHEDULER_add_now (&start_fresh_diagnostic, + md); +} + + +/** * Decide whether to emit a merchant failure warning now. * * @param fc failure class @@ -366,6 +644,21 @@ log_file_descriptor_usage (void) void PAIVANA_HTTPD_payment_shutdown () { + while (NULL != diagnostic_head) + { + struct MerchantTransportDiagnostic *md = diagnostic_head; + + if (NULL != md->task) + GNUNET_SCHEDULER_cancel (md->task); + if (NULL != md->job) + GNUNET_CURL_job_cancel (md->job); + GNUNET_CONTAINER_DLL_remove (diagnostic_head, + diagnostic_tail, + md); + GNUNET_free (md->url); + GNUNET_free (md->order_id); + GNUNET_free (md); + } while (NULL != ph_head) { struct PayRequest *ph = ph_head; @@ -468,7 +761,7 @@ start_merchant_order_lookup (struct PayRequest *ph, GNUNET_assert (NULL == ph->co); ph->co = TALER_MERCHANT_get_private_order_create (PH_merchant_ctx, - PH_merchant_base_url, + PH_merchant_internal_url, ph->order_id); if (NULL == ph->co) return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE; @@ -742,7 +1035,7 @@ order_status_cb (struct PayRequest *ph, "Merchant backend at `%s' answered order `%s' after %u" " consecutive lookup%s without an HTTP response (%u" " repetitive diagnostic%s suppressed)\n", - PH_merchant_base_url, + PH_merchant_internal_url, ph->order_id, merchant_transport_failures, (1 == merchant_transport_failures) ? "" : "s", @@ -869,7 +1162,7 @@ order_status_cb (struct PayRequest *ph, GNUNET_log (GNUNET_ERROR_TYPE_ERROR, "Merchant backend at `%s' rejected our credentials (HTTP" " %u); check MERCHANT_ACCESS_TOKEN\n", - PH_merchant_base_url, + PH_merchant_internal_url, osr->hr.http_status); ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_REFUSED, NULL, @@ -899,7 +1192,7 @@ order_status_cb (struct PayRequest *ph, "Merchant backend at `%s' sent an unusable reply for" " order `%s' after %s (%u similar diagnostic%s" " suppressed)\n", - PH_merchant_base_url, + PH_merchant_internal_url, ph->order_id, elapsed_s, suppressed, @@ -928,7 +1221,7 @@ order_status_cb (struct PayRequest *ph, " concurrent merchant lookup%s including this one; %u" " consecutive transport failure%s; %u similar" " diagnostic%s suppressed)\n", - PH_merchant_base_url, + PH_merchant_internal_url, ph->order_id, timeout_s, elapsed_s, @@ -967,7 +1260,7 @@ order_status_cb (struct PayRequest *ph, " (%u concurrent merchant lookup%s including this one;" " %u consecutive transport failure%s; %u similar" " diagnostic%s suppressed)\n", - PH_merchant_base_url, + PH_merchant_internal_url, ph->order_id, elapsed_s, timeout_s, @@ -977,6 +1270,7 @@ order_status_cb (struct PayRequest *ph, (1 == merchant_transport_failures) ? "" : "s", suppressed, (1 == suppressed) ? "" : "s"); + queue_fresh_diagnostic (ph); } ph->response = make_merchant_error (TALER_EC_PAIVANA_BACKEND_REFUSED, ph->order_id, diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -288,7 +288,7 @@ load_timeout (void *cls) GNUNET_log (GNUNET_ERROR_TYPE_ERROR, "Merchant backend at `%s' did not answer our template queries" " within %s; giving up instead of never starting to serve\n", - PH_merchant_base_url, + PH_merchant_internal_url, GNUNET_STRINGS_relative_time_to_string (TEMPLATE_LOAD_TIMEOUT, true)); PH_global_ret = EXIT_FAILURE; @@ -319,7 +319,7 @@ templates_ready (void) " refusing to start, as every request would then be" " forwarded for free. Pass -n if serving the site without" " a paywall is what you want.\n", - PH_merchant_base_url); + PH_merchant_internal_url); PH_global_ret = EXIT_NOTCONFIGURED; GNUNET_SCHEDULER_shutdown (); return; @@ -1271,7 +1271,7 @@ start_template_fetches (void) GNUNET_assert (TLS_WAITING == t->load_state); t->gt = TALER_MERCHANT_get_private_template_create ( PH_merchant_ctx, - PH_merchant_base_url, + PH_merchant_internal_url, t->template_id); if (NULL == t->gt) { @@ -1409,7 +1409,7 @@ PAIVANA_HTTPD_load_templates () &load_timeout, NULL); gpt = TALER_MERCHANT_get_private_templates_create (PH_merchant_ctx, - PH_merchant_base_url); + PH_merchant_internal_url); if (NULL == gpt) { GNUNET_log (GNUNET_ERROR_TYPE_ERROR, diff --git a/src/tests/README b/src/tests/README @@ -901,18 +901,24 @@ the expected backend. Payment-backend failure diagnostics ----------------------------------- -`test_payment_backend_failure.sh` starts paivana against a small Python -merchant stub and first drops an order lookup before sending any HTTP -response. Paivana must retry that idempotent GET once within its original -five-second budget and return the second attempt's response. The test then -stops the stub: a redemption against the now-refused port must return promptly -as 502 / error 9801, with the elapsed time and active/consecutive lookup counts -in the log and `merchant_http_status: 0' in the error JSON; it must not claim -that the five-second deadline elapsed. The 404 cases likewise assert the -actual merchant status in that field. The stub is restarted to prove that a -real HTTP response resets the consecutive-failure count, and finally holds an -order lookup open long enough to verify the true timeout path remains 504 / -error 11. It needs neither PostgreSQL nor a full Taler deployment. +`test_payment_backend_failure.sh` starts Paivana against a small Python +merchant stub through `merchant_fault_proxy.py`. Paivana advertises a public +HTTPS merchant URL while its template and order requests use a distinct HTTP +URL through the proxy's Unix listener, including the full merchant-instance +prefix. The test verifies that the internal URL does not leak into the +paywall, and that malformed internal URLs fail at startup. Template and +warm-up order requests leave a real persistent merchant connection idle. The +fault frontend then resets the next order lookup on that reused connection +before forwarding or access-logging it, also covering libcurl's internal fresh +retry and Paivana's application retry. Paivana must return promptly as 502 / +error 9801 with `merchant_http_status: 0`, while its forced-fresh diagnostic +and five separate curl processes all reach the merchant. The test explicitly +verifies that none of the failed attempts appears in either the frontend or +merchant access log. It then stops the stub to cover a refused port, restarts +it to prove a real HTTP response resets the consecutive-failure count, and +finally holds an order lookup open long enough to retain the 504 / error 11 +timeout path. This deterministic version needs neither PostgreSQL nor a full +Taler deployment. The paywall suite @@ -925,6 +931,20 @@ and anastasis suites start theirs -- creates a Paivana template on the merchant instance, buys access with `taler-wallet-cli`, and checks what the daemon does with the result. 31 checks, about 25 seconds. +The production failure has a real-stack reproduction mode: + + PAIVANA_REPRO_MERCHANT_POOL_FAILURE=1 \ + meson test -C build --print-errorlogs paywall + +In this mode the same fault frontend sits between Paivana and the real +PostgreSQL-backed merchant, but remains unarmed through startup, withdrawal and +payment. Once the wallet has paid a genuine order, the test arms exactly that +order path and proves all of the reported production properties together: +Paivana returns 502 / code 9801 / merchant status zero; the first reset was on +an idle pooled connection; no failed request reached the frontend access log; +the forced-fresh diagnostic receives HTTP 200; and five subsequent fresh order +status requests all succeed through the same frontend. + It skips (exit 77) rather than failing when the environment cannot support it: no `taler-unified-setup.sh`, `taler-wallet-cli`, `taler-merchant-httpd`, `jq`, `python3` or PostgreSQL, no built paywall diff --git a/src/tests/merchant_fault_proxy.py b/src/tests/merchant_fault_proxy.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Merchant frontend that can reset selected requests before access log. + +The proxy is deliberately small and HTTP/1.1-only. It accepts TCP or Unix +socket clients, optionally terminates TLS, keeps client and upstream +connections alive, and forwards one +request/response at a time. For the configured path, the first N requests are +reset after their headers have arrived but before a byte is sent upstream. +Consequently its FAULT log proves the transport attempt happened, while +neither its ACCESS log nor the merchant backend sees an HTTP request. +""" + +import argparse +import os +import socket +import ssl +import struct +import threading +import time +from urllib.parse import urlsplit + + +def log(message): + print(message, flush=True) + + +def recv_until(sock, buffer, marker): + while marker not in buffer: + chunk = sock.recv(65536) + if not chunk: + return None, b"" + buffer += chunk + if len(buffer) > 1024 * 1024: + raise RuntimeError("HTTP header exceeded 1 MiB") + head, buffer = buffer.split(marker, 1) + return head + marker, buffer + + +def content_length(header): + for line in header.split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.strip().lower() == b"content-length": + return int(value.strip()) + return 0 + + +def connection_closes(header): + for line in header.split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.strip().lower() == b"connection": + return b"close" in value.lower() + return False + + +def transfer_is_chunked(header): + for line in header.split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.strip().lower() == b"transfer-encoding": + return b"chunked" in value.lower() + return False + + +def relay_exact(source, destination, initial, length): + data = initial + if len(data) > length: + destination.sendall(data[:length]) + return data[length:] + if data: + destination.sendall(data) + length -= len(data) + while length: + chunk = source.recv(min(65536, length)) + if not chunk: + raise EOFError("connection closed in fixed-length HTTP body") + destination.sendall(chunk) + length -= len(chunk) + return b"" + + +def relay_chunked(source, destination, initial): + buffer = initial + while True: + line, buffer = recv_until(source, buffer, b"\r\n") + if line is None: + raise EOFError("connection closed in chunk-size line") + destination.sendall(line) + size_text = line[:-2].split(b";", 1)[0] + size = int(size_text, 16) + if size: + buffer = relay_exact(source, destination, buffer, size + 2) + continue + # The zero chunk is followed by zero or more trailer lines and one + # blank line. Reading a line at a time also handles the usual no- + # trailer form, which contains only that final CRLF. + while True: + trailer, buffer = recv_until(source, buffer, b"\r\n") + if trailer is None: + raise EOFError("connection closed in chunk trailers") + destination.sendall(trailer) + if trailer == b"\r\n": + return buffer + + +def reset_connection(sock): + try: + sock.setsockopt(socket.SOL_SOCKET, + socket.SO_LINGER, + struct.pack("ii", 1, 0)) + except OSError: + pass + try: + sock.close() + except OSError: + pass + + +class FaultBudget: + def __init__(self, path, count, control_file): + self.path = path + self.remaining = count + self.issued = 0 + self.control_file = control_file + self.control_version = None + self.lock = threading.Lock() + + def refresh(self): + if not self.control_file: + return + try: + stat = os.stat(self.control_file) + version = (stat.st_mtime_ns, stat.st_size) + if version == self.control_version: + return + with open(self.control_file, encoding="utf-8") as stream: + fields = stream.read().strip().split() + except FileNotFoundError: + return + if len(fields) != 2: + raise RuntimeError( + "fault control file must contain: PATH COUNT") + self.path = fields[0] + self.remaining = int(fields[1]) + self.issued = 0 + self.control_version = version + log(f"ARM path={self.path} count={self.remaining}") + + def consume(self, path): + with self.lock: + self.refresh() + if path != self.path or self.remaining == 0: + return None + self.remaining -= 1 + self.issued += 1 + return self.issued + + +class Proxy: + def __init__(self, args): + self.args = args + self.faults = FaultBudget(args.fault_path, + args.fault_count, + args.control_file) + self.context = None + if args.cert: + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.minimum_version = ssl.TLSVersion.TLSv1_2 + self.context.set_alpn_protocols(["http/1.1"]) + self.context.load_cert_chain(args.cert, args.key) + self.connection_id = 0 + self.connection_lock = threading.Lock() + + def next_connection_id(self): + with self.connection_lock: + self.connection_id += 1 + return self.connection_id + + def handle(self, raw_client): + connection_id = self.next_connection_id() + stage = "TLS handshake" + if self.context: + try: + client = self.context.wrap_socket(raw_client, server_side=True) + except (ssl.SSLError, OSError): + raw_client.close() + return + else: + client = raw_client + try: + stage = "upstream connect" + upstream = socket.create_connection( + (self.args.upstream_host, self.args.upstream_port), timeout=2) + upstream.settimeout(self.args.io_timeout) + client.settimeout(self.args.io_timeout) + except OSError as exc: + log(f"UPSTREAM_CONNECT_ERROR connection={connection_id} error={exc}") + reset_connection(client) + return + + client_buffer = b"" + upstream_buffer = b"" + prior_requests = 0 + last_response = time.monotonic() + try: + while True: + stage = "request headers from client" + request, client_buffer = recv_until( + client, client_buffer, b"\r\n\r\n") + if request is None: + return + request_line = request.split(b"\r\n", 1)[0].decode( + "iso-8859-1", "replace") + parts = request_line.split(" ") + if len(parts) != 3: + raise RuntimeError(f"malformed request line {request_line!r}") + method, target, _ = parts + path = urlsplit(target).path + idle_ms = int((time.monotonic() - last_response) * 1000) + fault_index = self.faults.consume(path) + if fault_index is not None: + log("FAULT " + f"index={fault_index} connection={connection_id} " + f"path={path} reused={'yes' if prior_requests else 'no'} " + f"prior_requests={prior_requests} idle_ms={idle_ms}") + reset_connection(client) + upstream.close() + return + + body_length = content_length(request) + stage = "request headers to upstream" + upstream.sendall(request) + stage = "request body to upstream" + client_buffer = relay_exact( + client, upstream, client_buffer, body_length) + prior_requests += 1 + log("ACCESS " + f"connection={connection_id} request={prior_requests} " + f"method={method} path={path}") + + stage = "response headers from upstream" + response, upstream_buffer = recv_until( + upstream, upstream_buffer, b"\r\n\r\n") + if response is None: + raise EOFError("upstream closed before response headers") + stage = "response headers to client" + client.sendall(response) + status_parts = response.split(b"\r\n", 1)[0].split(b" ") + no_body = (method == "HEAD" or + (len(status_parts) > 1 and + (status_parts[1][:1] == b"1" or + status_parts[1] in (b"204", b"304")))) + if transfer_is_chunked(response): + stage = "chunked response body to client" + upstream_buffer = relay_chunked( + upstream, client, upstream_buffer) + elif not no_body: + length = content_length(response) + if length: + stage = "fixed response body to client" + upstream_buffer = relay_exact( + upstream, client, upstream_buffer, length) + last_response = time.monotonic() + if connection_closes(request) or connection_closes(response): + return + except (EOFError, OSError, RuntimeError, ValueError) as exc: + log(f"CONNECTION_ERROR connection={connection_id} " + f"stage={stage} error={exc}") + finally: + try: + client.close() + except OSError: + pass + try: + upstream.close() + except OSError: + pass + + def serve(self): + if self.args.listen_unix: + if os.path.exists(self.args.listen_unix): + raise RuntimeError( + f"Unix listener path already exists: {self.args.listen_unix}") + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self.args.listen_unix) + listen_address = self.args.listen_unix + else: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((self.args.listen_host, self.args.listen_port)) + listen_address = (f"{listener.getsockname()[0]}:" + f"{listener.getsockname()[1]}") + listener.listen(128) + log(f"READY {listen_address}") + while True: + client, _ = listener.accept() + thread = threading.Thread(target=self.handle, + args=(client,), + daemon=True) + thread.start() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--listen-host", default="127.0.0.1") + listen = parser.add_mutually_exclusive_group(required=True) + listen.add_argument("--listen-port", type=int) + listen.add_argument("--listen-unix") + parser.add_argument("--upstream-host", default="127.0.0.1") + parser.add_argument("--upstream-port", type=int, required=True) + parser.add_argument("--fault-path") + parser.add_argument("--fault-count", type=int, default=2) + parser.add_argument("--control-file") + parser.add_argument("--io-timeout", type=float, default=300) + parser.add_argument("--cert") + parser.add_argument("--key") + args = parser.parse_args() + if bool(args.cert) != bool(args.key): + parser.error("--cert and --key must be specified together") + if not args.fault_path and not args.control_file: + parser.error("one of --fault-path or --control-file is required") + Proxy(args).serve() + + +if __name__ == "__main__": + main() diff --git a/src/tests/payment_backend_stub.py b/src/tests/payment_backend_stub.py @@ -15,6 +15,7 @@ TEMPLATE_ID = "premium" TEMPLATE_COUNT = int(os.environ.get("PAIVANA_STUB_TEMPLATE_COUNT", "1")) CONTRACT_PADDING = int(os.environ.get("PAIVANA_STUB_CONTRACT_PADDING", "0")) DETAIL_DELAY = float(os.environ.get("PAIVANA_STUB_DETAIL_DELAY", "0")) +BASE_PATH = os.environ.get("PAIVANA_STUB_BASE_PATH", "").rstrip("/") detail_lock = threading.Lock() active_details = 0 retry_order_dropped = False @@ -43,7 +44,6 @@ class Handler(BaseHTTPRequestHandler): self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(encoded))) - self.send_header("Connection", "close") self.end_headers() self.wfile.write(encoded) @@ -52,6 +52,11 @@ class Handler(BaseHTTPRequestHandler): self.reply(401, {"code": 2000, "hint": "wrong bearer token"}) return path = urlsplit(self.path).path + if BASE_PATH: + if not path.startswith(BASE_PATH + "/"): + self.reply(404, {"code": 2906, "hint": "wrong base path"}) + return + path = path[len(BASE_PATH):] if path == "/private/templates": self.reply( 200, diff --git a/src/tests/test_payment_backend_failure.sh b/src/tests/test_payment_backend_failure.sh @@ -11,17 +11,23 @@ SRCDIR="${SRCDIR:-$(here)}" BUILDDIR="${BUILDDIR:-$PWD}" PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}" PORT_BASE="${PAIVANA_PORT_BASE:-18400}" -MERCHANT_PORT=$((PORT_BASE + 120)) +MERCHANT_BACKEND_PORT=$((PORT_BASE + 120)) PAIVANA_PORT=$((PORT_BASE + 121)) SCRATCH="$(mktemp -d -t paivana-payment-failure.XXXXXX)" +MERCHANT_SOCKET="$SCRATCH/merchant.sock" +PUBLIC_MERCHANT_URL="https://public-merchant.example/instances/paivana/" +INTERNAL_MERCHANT_URL="http://merchant.invalid/instances/paivana/" STUB_PID="" +PROXY_PID="" PAIVANA_PID="" function cleanup() { set +e [ -n "$STUB_PID" ] && kill -TERM "$STUB_PID" 2>/dev/null + [ -n "$PROXY_PID" ] && kill -TERM "$PROXY_PID" 2>/dev/null [ -n "$PAIVANA_PID" ] && kill -TERM "$PAIVANA_PID" 2>/dev/null wait "$STUB_PID" 2>/dev/null + wait "$PROXY_PID" 2>/dev/null wait "$PAIVANA_PID" 2>/dev/null if [ "${KEEP_TMP:-0}" = "1" ]; then echo "Temp files kept in $SCRATCH" >&2 @@ -49,23 +55,53 @@ function wait_for_port() { return 1 } +function wait_for_unix_socket() { + local path="$1" pid="$2" tries=50 + while [ "$tries" -gt 0 ]; do + if [ -S "$path" ] && + grep -Fxq "READY $path" "$SCRATCH/proxy.log" + then + return 0 + fi + kill -0 "$pid" 2>/dev/null || return 1 + sleep 0.1 + tries=$((tries - 1)) + done + return 1 +} + function fail() { echo "FAIL: $*" >&2 echo "==> paivana.log <==" >&2 tail -n 80 "$SCRATCH/paivana.log" >&2 || true echo "==> merchant.log <==" >&2 tail -n 40 "$SCRATCH/merchant.log" >&2 || true + echo "==> proxy.log <==" >&2 + tail -n 60 "$SCRATCH/proxy.log" >&2 || true exit 1 } function start_stub() { - python3 "$SRCDIR/payment_backend_stub.py" "$MERCHANT_PORT" \ + PAIVANA_STUB_BASE_PATH=/instances/paivana \ + python3 "$SRCDIR/payment_backend_stub.py" "$MERCHANT_BACKEND_PORT" \ >>"$SCRATCH/merchant.log" 2>&1 & STUB_PID=$! - wait_for_port "$MERCHANT_PORT" "$STUB_PID" \ + wait_for_port "$MERCHANT_BACKEND_PORT" "$STUB_PID" \ || fail "merchant stub did not start" } +function start_proxy() { + python3 "$SRCDIR/merchant_fault_proxy.py" \ + --listen-unix "$MERCHANT_SOCKET" \ + --upstream-port "$MERCHANT_BACKEND_PORT" \ + --fault-path /instances/paivana/private/orders/diagnostic-order \ + --fault-count 3 \ + >>"$SCRATCH/proxy.log" 2>&1 & + PROXY_PID=$! + wait_for_unix_socket "$MERCHANT_SOCKET" "$PROXY_PID" \ + || fail "merchant fault frontend did not start" +} + function redemption_body() { local order="$1" printf '{"order_id":"%s","website":"http://127.0.0.1:%u/article",' \ @@ -106,7 +142,8 @@ PY command -v curl >/dev/null 2>&1 || { echo "SKIP: curl not found"; exit 77; } command -v python3 >/dev/null 2>&1 || { echo "SKIP: python3 not found"; exit 77; } [ -x "$PAIVANA_HTTPD" ] || { echo "SKIP: paivana-httpd not found"; exit 77; } -if ! port_is_free "$MERCHANT_PORT" || ! port_is_free "$PAIVANA_PORT"; then +if ! port_is_free "$MERCHANT_BACKEND_PORT" || \ + ! port_is_free "$PAIVANA_PORT"; then echo "SKIP: test ports are occupied; change PAIVANA_PORT_BASE" >&2 exit 77 fi @@ -121,7 +158,9 @@ cat >"$SCRATCH/paivana.conf" <<EOF [paivana] DESTINATION_BASE_URL = http://127.0.0.1:9/ BASE_URL = http://127.0.0.1:$PAIVANA_PORT/ -MERCHANT_BACKEND_URL = http://127.0.0.1:$MERCHANT_PORT/ +MERCHANT_BACKEND_URL = $PUBLIC_MERCHANT_URL +MERCHANT_BACKEND_INTERNAL_URL = $INTERNAL_MERCHANT_URL +MERCHANT_BACKEND_UNIX_PATH = $MERCHANT_SOCKET MERCHANT_ACCESS_TOKEN = secret-token:stub SECRET = payment-backend-failure-test SERVE = tcp @@ -134,23 +173,113 @@ CONNECTION_LIMIT = 4 PAYMENT_CONNECTION_LIMIT = 2 EOF +function expect_invalid_internal_url() { + local value="$1" label="$2" + local cfg="$SCRATCH/invalid-internal.conf" + local log="$SCRATCH/invalid-internal.log" + + sed "s|^MERCHANT_BACKEND_INTERNAL_URL =.*|MERCHANT_BACKEND_INTERNAL_URL = $value|" \ + "$SCRATCH/paivana.conf" > "$cfg" + if timeout 3 "$PAIVANA_HTTPD" -c "$cfg" -L WARNING >"$log" 2>&1; then + fail "Paivana accepted $label internal merchant URL" + fi + grep -q 'MERCHANT_BACKEND_INTERNAL_URL' "$log" || \ + fail "Paivana did not diagnose $label internal merchant URL" +} + +expect_invalid_internal_url 'not-a-web-url/' malformed +expect_invalid_internal_url 'http://merchant.invalid/instances/paivana' \ + unterminated + start_stub +start_proxy "$PAIVANA_HTTPD" -c "$SCRATCH/paivana.conf" -L DEBUG \ >"$SCRATCH/paivana.log" 2>&1 & PAIVANA_PID=$! wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" || fail "paivana did not start" +grep -q 'GET /instances/paivana/private/templates ' \ + "$SCRATCH/merchant.log" || fail "template request lost merchant instance prefix" +grep -q 'GET /instances/paivana/private/templates/premium ' \ + "$SCRATCH/merchant.log" || fail "template detail lost merchant instance prefix" -# Losing a merchant connection before any response is safe to retry because -# the order lookup is a GET. The stub drops retry-order's first lookup and -# answers its second, reproducing the stale-idle-connection failure seen in -# production without making the test depend on a TCP keepalive timeout. -read -r status elapsed < <(redeem retry-order) -[ "$status" = "404" ] || fail "retried merchant lookup returned HTTP $status" -expect_error 9802 retry-order 404 || fail "unexpected retry response JSON" +# The server-side URL is deliberately unreachable over TCP and cleartext over +# the Unix socket, while every browser-facing value must retain public HTTPS. +status=$(curl -sS -D "$SCRATCH/paywall.headers" \ + -o "$SCRATCH/paywall.body" -w '%{http_code}' \ + "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana/templates/premium") +[ "$status" = 402 ] || fail "paywall page returned HTTP $status" +grep -qi '^Paivana: taler://pay-template/public-merchant.example/instances/paivana/premium' \ + "$SCRATCH/paywall.headers" || fail "paywall header did not use public URL" +grep -qi "connect-src 'self' https://public-merchant.example" \ + "$SCRATCH/paywall.headers" || fail "paywall CSP did not use public URL" +grep -q 'https://public-merchant.example/instances/paivana/' \ + "$SCRATCH/paywall.body" || fail "paywall body did not use public URL" +if grep -q 'merchant.invalid' "$SCRATCH/paywall.body"; then + fail "internal merchant URL leaked into paywall body" +fi + +# Warm the merchant pool with an ordinary order lookup. Together with the +# startup template requests this leaves a known-good persistent connection in +# Paivana's merchant curl context, which is what the production process has +# between its infrequent genuine payment attempts. +read -r status elapsed < <(redeem warm-order) +[ "$status" = "404" ] || fail "merchant warm-up returned HTTP $status" +expect_error 9802 warm-order 404 || fail "unexpected warm-up response JSON" +[ "$(grep -c '/private/orders/warm-order' "$SCRATCH/merchant.log")" = 1 ] || \ + fail "merchant warm-up did not use exactly one request" + +# The frontend has kept the merchant connection from warm-order alive. +# Let it become genuinely idle, then have the frontend reset the next three +# matching transport attempts before forwarding or access-logging them. The +# first fault must therefore be an idle reused TCP connection. libcurl retries +# that reused-connection failure internally, and Paivana retries the completed +# status-zero operation once more; the observed topology therefore requires +# three resets. A fresh request remains healthy once that window has passed. +sleep 0.3 +read -r status elapsed < <(redeem diagnostic-order) +[ "$status" = "502" ] || fail "diagnostic trigger returned HTTP $status" +expect_error 9801 diagnostic-order 0 || fail "unexpected diagnostic-trigger JSON" +for _ in $(seq 1 50); do + grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order' \ + "$SCRATCH/paivana.log" && break + sleep 0.1 +done +grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order.*completed HTTP status 404' \ + "$SCRATCH/paivana.log" || fail "successful fresh-connection diagnostic missing" +grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order.*new connections 1' \ + "$SCRATCH/paivana.log" || fail "fresh diagnostic did not report a new connection" grep -q "retrying once within the original 5 s deadline" \ - "$SCRATCH/paivana.log" || fail "merchant retry diagnostic missing" -[ "$(grep -c '/private/orders/retry-order' "$SCRATCH/merchant.log")" = 2 ] || \ - fail "merchant lookup was not attempted exactly twice" + "$SCRATCH/paivana.log" || fail "Paivana application retry diagnostic missing" +grep -q 'FAULT index=1 .*path=/instances/paivana/private/orders/diagnostic-order reused=yes' \ + "$SCRATCH/proxy.log" || fail "first fault did not use the idle merchant connection" +[ "$(grep -c 'FAULT .*path=/instances/paivana/private/orders/diagnostic-order' "$SCRATCH/proxy.log")" = 3 ] || \ + fail "frontend did not cover libcurl and Paivana retry attempts" +[ "$(grep -c '/private/orders/diagnostic-order' "$SCRATCH/merchant.log")" = 1 ] || \ + fail "merchant saw a failed order request or missed the fresh diagnostic" +[ "$(grep -c 'ACCESS .*path=/instances/paivana/private/orders/diagnostic-order' "$SCRATCH/proxy.log")" = 1 ] || \ + fail "frontend access log contains a failed order request" +if grep -q 'secret-token:stub' "$SCRATCH/paivana.log"; then + fail "merchant bearer token leaked into Paivana's log" +fi + +for _ in $(seq 1 5); do + status=$(curl -sS -H 'Authorization: Bearer secret-token:stub' \ + --unix-socket "$MERCHANT_SOCKET" \ + -o /dev/null -w '%{http_code}' \ + "${INTERNAL_MERCHANT_URL}private/orders/diagnostic-order") + [ "$status" = "404" ] || fail "fresh merchant curl returned HTTP $status" +done +[ "$(grep -c '/private/orders/diagnostic-order' "$SCRATCH/merchant.log")" = 6 ] || \ + fail "fresh order-status curls did not all reach the merchant" + +# A normal merchant response resets both warning sampling and the consecutive +# operational failure count. The diagnostic itself deliberately does not. +read -r status elapsed < <(redeem diagnostic-recovery-order) +[ "$status" = "404" ] || fail "diagnostic recovery returned HTTP $status" +expect_error 9802 diagnostic-recovery-order 404 || \ + fail "unexpected diagnostic-recovery JSON" +grep -q 'answered order .*diagnostic-recovery-order.* after 1 consecutive lookup' \ + "$SCRATCH/paivana.log" || fail "diagnostic recovery log missing" # A refused connection completes immediately. It must no longer be # presented as a five-second timeout. @@ -168,6 +297,13 @@ grep -q "early transport failure" "$SCRATCH/paivana.log" \ || fail "early-failure diagnostic missing from log" grep -q "1 concurrent merchant lookup including this one" "$SCRATCH/paivana.log" \ || fail "active lookup count missing from log" +for _ in $(seq 1 50); do + grep -q 'Forced-fresh merchant diagnostic for order `transport-failure' \ + "$SCRATCH/paivana.log" && break + sleep 0.1 +done +grep -q 'Forced-fresh merchant diagnostic for order `transport-failure.*completed HTTP status 0' \ + "$SCRATCH/paivana.log" || fail "failed fresh-connection diagnostic missing" # A real HTTP response proves recovery and resets the consecutive # transport-failure counter. diff --git a/src/tests/test_paywall.sh b/src/tests/test_paywall.sh @@ -37,6 +37,10 @@ # from one to the other. paivana_id.py re-derives it from the definition # src/frontend/paywall.js implements, which is what makes this a test of # both ends rather than of one end twice. +# +# Set PAIVANA_REPRO_MERCHANT_POOL_FAILURE=1 to pay a genuine order and then +# reproduce the production status-zero failure through merchant_fault_proxy.py +# instead of completing the successful redemption checks. set -eu @@ -48,9 +52,11 @@ set -eu PORT_BASE="${PAIVANA_PORT_BASE:-18400}" PAIVANA_PORT=$((PORT_BASE + 110)) ORIGIN_PORT=$((PORT_BASE + 111)) +MERCHANT_PROXY_PORT=$((PORT_BASE + 112)) MERCHANT_PORT=9966 EXCHANGE_PORT=8081 BANK_PORT=8082 +FAULT_REPRO="${PAIVANA_REPRO_MERCHANT_POOL_FAILURE:-0}" function here() { cd -- "$(dirname -- "$0")" && pwd @@ -164,6 +170,10 @@ for p in "$PAIVANA_PORT" "$ORIGIN_PORT" "$MERCHANT_PORT" "$EXCHANGE_PORT" "$BANK do port_is_free "$p" || BUSY="$BUSY $p" done +if [ "$FAULT_REPRO" = 1 ] && ! port_is_free "$MERCHANT_PROXY_PORT" +then + BUSY="$BUSY $MERCHANT_PROXY_PORT" +fi if [ -n "$BUSY" ] then # The Taler ports are the ones the merchant suite uses, so this is @@ -241,6 +251,7 @@ setup -c "$CONF" \ -f -d x-taler-bank -u exchange-account-2 MERCHANT_URL="http://localhost:$MERCHANT_PORT/" +PAIVANA_MERCHANT_URL="$MERCHANT_URL" AUTH="Authorization: Bearer secret-token:super_secret" RESP="$SCRATCH/response.json" @@ -264,6 +275,22 @@ STATUS=$(merchant_post management/instances \ [ "$STATUS" = "204" ] || exit_fail "creating the instance: got $STATUS, $(cat "$RESP")" echo " OK" +if [ "$FAULT_REPRO" = 1 ] +then + echo -n "Starting the merchant fault frontend ..." + python3 "$SRCDIR/merchant_fault_proxy.py" \ + --listen-port "$MERCHANT_PROXY_PORT" \ + --upstream-port "$MERCHANT_PORT" \ + --control-file "$SCRATCH/merchant-fault.control" \ + > "$SCRATCH/merchant-proxy.log" 2>&1 & + MERCHANT_PROXY_PID=$! + PIDS="$PIDS $MERCHANT_PROXY_PID" + wait_for_port "$MERCHANT_PROXY_PORT" "$MERCHANT_PROXY_PID" \ + || exit_fail "the merchant fault frontend did not start" + PAIVANA_MERCHANT_URL="http://127.0.0.1:$MERCHANT_PROXY_PORT/" + echo " OK" +fi + echo -n "Configuring merchant bank account ..." STATUS=$(merchant_post private/accounts \ '{"payto_uri":"payto://x-taler-bank/localhost/fortythree?receiver-name=fortythree"}') @@ -297,7 +324,7 @@ cat > "$SCRATCH/paivana.conf" <<EOF [paivana] DESTINATION_BASE_URL = http://localhost:$ORIGIN_PORT/ BASE_URL = $BASE_URL -MERCHANT_BACKEND_URL = $MERCHANT_URL +MERCHANT_BACKEND_URL = $PAIVANA_MERCHANT_URL MERCHANT_ACCESS_TOKEN = secret-token:super_secret # A fixed secret so that a cookie minted here can be reasoned about; a # real deployment must not do this (see the manual on SECRET). @@ -375,8 +402,14 @@ else fi PAIVANA_HEADER=$(grep -i '^paivana:' "$SCRATCH/page.hdr" | tr -d '\r' | cut -d' ' -f2-) +if [ "$FAULT_REPRO" = 1 ] +then + EXPECTED_PAY_TEMPLATE="taler+http://pay-template/127.0.0.1:$MERCHANT_PROXY_PORT/premium" +else + EXPECTED_PAY_TEMPLATE="taler+http://pay-template/localhost:$MERCHANT_PORT/premium" +fi case "$PAIVANA_HEADER" in - "taler+http://pay-template/localhost:$MERCHANT_PORT/premium") + "$EXPECTED_PAY_TEMPLATE") ok "the Paivana header carries the pay-template URI" ;; *) @@ -555,7 +588,7 @@ echo "-- redeeming --" function redeem() { local website="$1" expiration="$2" nonce="$3" order="$4" hdr="$5" - curl -s -D "$hdr" -o /dev/null -w "%{http_code}" -X POST \ + curl -s -D "$hdr" -o "$SCRATCH/redeem.body" -w "%{http_code}" -X POST \ -H "Content-Type: application/json" \ "${BASE_URL}.well-known/paivana" \ -d "$(jq -n --arg o "$order" --arg n "$nonce" \ @@ -563,7 +596,82 @@ function redeem() { '{order_id:$o,nonce:$n,expiration:{t_s:$e},website:$w}')" } +if [ "$FAULT_REPRO" = 1 ] +then + # Atomic replacement keeps the proxy from observing a truncated control + # file. Three resets cover libcurl's retry of an idle reused connection + # and Paivana's one application-level retry; the forced-fresh diagnostic + # that follows is allowed through to the real merchant. + printf '/private/orders/%s 3\n' "$ORDER_ID" \ + > "$SCRATCH/merchant-fault.control.new" + mv "$SCRATCH/merchant-fault.control.new" \ + "$SCRATCH/merchant-fault.control" +fi + STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/redeem.hdr") +if [ "$FAULT_REPRO" = 1 ] +then + if [ "$STATUS" = 502 ] && \ + [ "$(jq -r '.code' < "$SCRATCH/redeem.body")" = 9801 ] && \ + [ "$(jq -r '.merchant_http_status' < "$SCRATCH/redeem.body")" = 0 ] + then + ok "Paivana reproduces 502/error 9801 with merchant_http_status zero" + else + fail "faulted redemption returned $STATUS: $(cat "$SCRATCH/redeem.body")" + fi + + for _ in $(seq 1 50) + do + grep -q "Forced-fresh merchant diagnostic for order \`$ORDER_ID'.*completed HTTP status 200" \ + "$SCRATCH/paivana.log" && break + sleep 0.1 + done + if grep -q "Forced-fresh merchant diagnostic for order \`$ORDER_ID'.*completed HTTP status 200" \ + "$SCRATCH/paivana.log" + then + ok "a forced-fresh request reaches the real merchant immediately afterwards" + else + fail "the forced-fresh merchant diagnostic did not recover" + fi + + if grep -q "FAULT index=1 .*path=/private/orders/$ORDER_ID reused=yes" \ + "$SCRATCH/merchant-proxy.log" && \ + [ "$(grep -c "FAULT .*path=/private/orders/$ORDER_ID" \ + "$SCRATCH/merchant-proxy.log")" = 3 ] + then + ok "the first failed transport used an idle pooled connection and all retries were covered" + else + fail "fault frontend did not exercise the expected pooled/retry path" + fi + + if [ "$(grep -c "ACCESS .*path=/private/orders/$ORDER_ID" \ + "$SCRATCH/merchant-proxy.log")" = 1 ] + then + ok "none of Paivana's failed attempts reached the frontend access log" + else + fail "a failed order lookup appeared in the frontend access log" + fi + + FRESH_URL="${PAIVANA_MERCHANT_URL}private/orders/${ORDER_ID}" + FRESH_URL="$FRESH_URL?session_id=$(urlenc "$PAIVANA_ID")" + FRESH_OK=1 + for _ in $(seq 1 5) + do + STATUS=$(curl -s -o "$RESP" -w "%{http_code}" -H "$AUTH" "$FRESH_URL") + [ "$STATUS" = 200 ] || FRESH_OK=0 + done + if [ "$FRESH_OK" = 1 ] + then + ok "five fresh order-status curls all succeed through the same frontend" + else + fail "a fresh order-status curl failed after the Paivana incident" + fi + + echo "=== $CHECKS checks, $FAILS failures ===" + [ "$FAILS" = 0 ] + exit +fi + if [ "$STATUS" = "303" ] then ok "a paid order is redeemed with 303"