paivana

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

merchant_stub.rs (7860B)


      1 /*
      2   This file is part of Paivana.
      3   Copyright (C) 2026 Taler Systems SA
      4 
      5   Paivana is free software; you can redistribute it and/or
      6   modify it under the terms of the GNU Affero General Public License
      7   as published by the Free Software Foundation; either version
      8   3, or (at your option) any later version.
      9 
     10   Paivana is distributed in the hope that it will be useful, but
     11   WITHOUT ANY WARRANTY; without even the implied warranty of
     12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13   GNU Affero General Public License for more details.
     14 
     15   You should have received a copy of the GNU Affero General Public
     16   License along with Paivana; see the file COPYING.  If not,
     17   write to the Free Software Foundation, Inc., 51 Franklin
     18   Street, Fifth Floor, Boston, MA 02110-1301, USA.
     19 */
     20 
     21 // merchant_stub: the two GET endpoints of the GNU Taler merchant
     22 // backend that paivana reads at startup, and nothing else.
     23 //
     24 // `benchmark.sh -m paywall' needs paivana running with the paywall
     25 // *on*, and paivana does not open its listen socket until it has
     26 // fetched a template: PAIVANA_HTTPD_load_templates() ->
     27 // check_templates() -> setup_template() -> templates_ready() ->
     28 // PAIVANA_HTTPD_serve_requests().  So something has to answer those
     29 // two requests before anything can be measured at all.
     30 //
     31 // Nothing after that exchange touches the backend again.  The paywall
     32 // page is rendered locally from the template, and the rendered
     33 // MHD_Response is then cached per (language, encoding) in
     34 // load_paywall(), so across the measurement window this process is
     35 // idle and would be equally idle if it were a real merchant.  That is
     36 // what makes a stub honest here where test_paywall.sh needs the real
     37 // thing: this benchmark never buys anything, so no code path that
     38 // distinguishes the two is ever reached.  `benchmark.sh' takes
     39 // PAIVANA_BENCH_MERCHANT_URL for anyone who wants to check that claim
     40 // against a live backend.
     41 //
     42 // The two bodies are shaped as the merchant client library parses
     43 // them -- merchant_api_get-private-templates.c wants `templates' with
     44 // `template_id' and `template_description', and
     45 // merchant_api_get-private-templates-TEMPLATE_ID.c wants
     46 // `template_description' and an object `template_contract' -- and the
     47 // contract itself is the one test_paywall.sh POSTs to a real backend,
     48 // so the page paivana renders here is the page it renders there.
     49 
     50 use std::env;
     51 use std::io::{BufRead, BufReader, Write};
     52 use std::net::{TcpListener, TcpStream};
     53 use std::thread;
     54 
     55 // The template paivana is expected to find.  Fixed rather than
     56 // configurable: benchmark.sh has to name it in the URL it measures.
     57 const TEMPLATE_ID: &str = "premium";
     58 
     59 fn body_templates() -> String {
     60     format!(
     61         "{{\"templates\":[{{\"template_id\":\"{}\",\
     62          \"template_description\":\"Paywalled content\"}}]}}",
     63         TEMPLATE_ID
     64     )
     65 }
     66 
     67 fn body_template() -> String {
     68     // website_regex `.*' and a single choice, i.e. what
     69     // test_paywall.sh creates.  One choice rather than several because
     70     // that is the shipped shape: with two or more the page grows a
     71     // selector (`has_choices'), which would make the measured page
     72     // depend on a decision this stub had made.
     73     String::from(
     74         "{\"template_description\":\"Paywalled content\",\
     75          \"template_contract\":{\"template_type\":\"paivana\",\
     76          \"summary\":\"Access to the article\",\
     77          \"website_regex\":\".*\",\
     78          \"max_pickup_duration\":{\"d_us\":3600000000},\
     79          \"choices\":[{\"amount\":\"TESTKUDOS:1\",\
     80          \"description\":\"One article\"}]}}",
     81     )
     82 }
     83 
     84 fn send(stream: &mut TcpStream, code: u16, reason: &str, body: &str) {
     85     // `Connection: close' for the same reason upstream_rs sends it
     86     // (see its client_loop()): this server answers one request per
     87     // connection and then drops the stream, and paivana's merchant
     88     // handles sit on a shared curl multi handle that would otherwise
     89     // reuse a connection we have already shut.
     90     let head = format!(
     91         "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\n\
     92          Content-Length: {}\r\nConnection: close\r\n\r\n",
     93         code,
     94         reason,
     95         body.len()
     96     );
     97     let _ = stream.write_all(head.as_bytes());
     98     let _ = stream.write_all(body.as_bytes());
     99 }
    100 
    101 fn client_loop(mut stream: TcpStream, token: &str) {
    102     let (method, path, authorized) = {
    103         let mut br = BufReader::new(&mut stream);
    104         let mut line = String::new();
    105         if br.read_line(&mut line).unwrap_or(0) == 0 {
    106             return;
    107         }
    108         let mut parts = line.trim_end().splitn(3, ' ');
    109         let method = match parts.next() {
    110             Some(m) => m.to_string(),
    111             None => return,
    112         };
    113         let path = match parts.next() {
    114             Some(p) => p.to_string(),
    115             None => return,
    116         };
    117         let mut authorized = false;
    118         loop {
    119             let mut h = String::new();
    120             if br.read_line(&mut h).unwrap_or(0) == 0 {
    121                 break;
    122             }
    123             let t = h.trim_end();
    124             if t.is_empty() {
    125                 break;
    126             }
    127             if let Some(idx) = t.find(':') {
    128                 if t[..idx].trim().eq_ignore_ascii_case("Authorization")
    129                     && t[idx + 1..].trim() == format!("Bearer {}", token)
    130                 {
    131                     authorized = true;
    132                 }
    133             }
    134         }
    135         (method, path, authorized)
    136     };
    137 
    138     eprintln!("merchant_stub: {} {}", method, path);
    139     // Checked rather than ignored: paivana building the Authorization
    140     // header out of MERCHANT_ACCESS_TOKEN is the one thing about this
    141     // exchange that could silently regress, and a 401 here makes
    142     // paivana say so ("Access to templates unauthorized") instead of
    143     // starting anyway and leaving the benchmark to measure a
    144     // configuration nobody would deploy.
    145     if !authorized {
    146         send(&mut stream, 401, "Unauthorized",
    147              "{\"code\":2000,\"hint\":\"merchant_stub: no or wrong bearer token\"}");
    148         return;
    149     }
    150     if method != "GET" {
    151         send(&mut stream, 405, "Method Not Allowed", "{}");
    152         return;
    153     }
    154     // The path arrives with whatever prefix MERCHANT_BACKEND_URL had;
    155     // benchmark.sh points it at the root, so these are exact.
    156     if path == "/private/templates" {
    157         send(&mut stream, 200, "OK", &body_templates());
    158         return;
    159     }
    160     if path == format!("/private/templates/{}", TEMPLATE_ID) {
    161         send(&mut stream, 200, "OK", &body_template());
    162         return;
    163     }
    164     send(&mut stream, 404, "Not Found",
    165          "{\"code\":2906,\"hint\":\"merchant_stub: no such endpoint\"}");
    166 }
    167 
    168 fn main() {
    169     // Same reasoning as upstream_rs: a port that does not parse must
    170     // be an error rather than a silent fallback, or the caller waits
    171     // out its readiness timeout on a port nothing ever bound.
    172     let port: u16 = match env::args().nth(1) {
    173         None => 8405,
    174         Some(s) => match s.parse::<u16>() {
    175             Ok(p) if p >= 1 => p,
    176             _ => {
    177                 eprintln!("invalid port {:?}", s);
    178                 std::process::exit(1);
    179             }
    180         },
    181     };
    182     let token = env::args().nth(2).unwrap_or_else(|| String::from("secret-token:stub"));
    183     // Loopback only: it hands out a merchant configuration to anyone
    184     // who asks with the right token, and has no business being
    185     // reachable from the network.
    186     let listener = TcpListener::bind(("127.0.0.1", port)).expect("bind failed");
    187     eprintln!("merchant_stub listening on port {}", port);
    188     for stream in listener.incoming() {
    189         match stream {
    190             Ok(s) => {
    191                 let token = token.clone();
    192                 thread::spawn(move || client_loop(s, &token));
    193             }
    194             Err(_) => continue,
    195         }
    196     }
    197 }