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 }