A batteries included HTTP/1.1 client in OCaml
at main 206 lines 6.4 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** HTTP CONNECT Tunneling for HTTPS via Proxy 7 8 Per RFC 9110 Section 9.3.6: The CONNECT method requests that the recipient 9 establish a tunnel to the destination origin server and, if successful, 10 thereafter restrict its behavior to blind forwarding of packets in both 11 directions. *) 12 13let src = Logs.Src.create "requests.proxy_tunnel" ~doc:"HTTPS proxy tunneling" 14 15module Log = (val Logs.src_log src : Logs.LOG) 16module Write = Eio.Buf_write 17module Read = Eio.Buf_read 18 19(** {1 Low-level Functions} *) 20 21let write_connect_request w ~proxy ~target_host ~target_port = 22 let target = Fmt.str "%s:%d" target_host target_port in 23 24 (* CONNECT request line per RFC 9110 Section 9.3.6 *) 25 Write.string w "CONNECT "; 26 Write.string w target; 27 Write.string w " HTTP/1.1\r\n"; 28 29 (* Host header is required *) 30 Write.string w "Host: "; 31 Write.string w target; 32 Write.string w "\r\n"; 33 34 (* Proxy-Authorization if configured *) 35 (match proxy.Proxy.auth with 36 | Some auth -> ( 37 (* Apply auth to get the Authorization header, then rename to Proxy-Authorization *) 38 let headers = Auth.apply auth Headers.empty in 39 match Headers.find `Authorization headers with 40 | Some value -> 41 Write.string w "Proxy-Authorization: "; 42 Write.string w value; 43 Write.string w "\r\n" 44 | None -> ()) 45 | None -> ()); 46 47 (* User-Agent for debugging *) 48 Write.string w "User-Agent: ocaml-requests\r\n"; 49 50 (* End of headers *) 51 Write.string w "\r\n"; 52 53 Log.debug (fun m -> 54 m "Wrote CONNECT request for %s via %s:%d" target proxy.Proxy.host 55 proxy.Proxy.port) 56 57let parse_connect_response r ~proxy ~target = 58 (* Parse status line - we just need version and status code *) 59 let version_str = 60 Read.take_while 61 (function 62 | 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' | '/' | '.' -> true | _ -> false) 63 r 64 in 65 Read.char ' ' r; 66 let status_str = 67 Read.take_while (function '0' .. '9' -> true | _ -> false) r 68 in 69 Read.char ' ' r; 70 let reason = Read.line r in 71 72 let status = 73 try int_of_string status_str 74 with Failure _ -> 75 raise 76 (Error.proxy_errorf ~host:proxy.Proxy.host 77 "Invalid status code in CONNECT response: %s" status_str) 78 in 79 80 Log.debug (fun m -> m "CONNECT response: %s %d %s" version_str status reason); 81 82 (* Read headers until empty line *) 83 let rec skip_headers () = 84 let line = Read.line r in 85 if line <> "" then skip_headers () 86 in 87 skip_headers (); 88 89 (* Check for success (2xx) *) 90 if status < 200 || status >= 300 then 91 raise 92 (Error.proxy_errorf ~host:proxy.Proxy.host "CONNECT to %s failed: %d %s" 93 target status reason); 94 95 Log.info (fun m -> 96 m "CONNECT tunnel established to %s via proxy %s:%d" target 97 proxy.Proxy.host proxy.Proxy.port) 98 99(** {1 Tunnel Establishment} *) 100 101let connect ~sw ~net ~proxy ~target_host ~target_port () = 102 let target = Fmt.str "%s:%d" target_host target_port in 103 104 Log.debug (fun m -> 105 m "Establishing CONNECT tunnel to %s via %s:%d" target proxy.Proxy.host 106 proxy.Proxy.port); 107 108 (* Connect to proxy server *) 109 let proxy_addr = 110 let addrs = 111 Eio.Net.getaddrinfo_stream net proxy.Proxy.host 112 ~service:(string_of_int proxy.Proxy.port) 113 in 114 match addrs with 115 | [] -> 116 raise 117 (Error.err 118 (Error.Dns_resolution_failed { hostname = proxy.Proxy.host })) 119 | addr :: _ -> addr 120 in 121 122 let flow = 123 try Eio.Net.connect ~sw net proxy_addr 124 with exn -> 125 raise 126 (Error.err 127 (Error.Tcp_connect_failed 128 { 129 host = proxy.Proxy.host; 130 port = proxy.Proxy.port; 131 reason = Printexc.to_string exn; 132 })) 133 in 134 135 Log.debug (fun m -> 136 m "Connected to proxy %s:%d" proxy.Proxy.host proxy.Proxy.port); 137 138 (* Send CONNECT request *) 139 Http_write.write_and_flush flow (fun w -> 140 write_connect_request w ~proxy ~target_host ~target_port); 141 142 (* Read and validate response *) 143 let buf_read = Read.of_flow ~max_size:65536 flow in 144 parse_connect_response buf_read ~proxy ~target; 145 146 (* Return the raw flow - caller is responsible for TLS wrapping *) 147 (flow :> [ `Close | `Flow | `R | `Shutdown | `W ] Eio.Resource.t) 148 149let default_tls_config ~target_host = 150 let authenticator = 151 match Ca_certs.authenticator () with 152 | Ok auth -> auth 153 | Error (`Msg msg) -> 154 Log.warn (fun m -> 155 m "Failed to load CA certificates: %s, using null authenticator" msg); 156 fun ?ip:_ ~host:_ _ -> Ok None 157 in 158 match Tls.Config.client ~authenticator () with 159 | Ok cfg -> cfg 160 | Error (`Msg msg) -> 161 raise 162 (Error.err 163 (Error.Tls_handshake_failed 164 { host = target_host; reason = "TLS config error: " ^ msg })) 165 166let connect_with_tls ~sw ~net ~clock:_ ~proxy ~target_host ~target_port 167 ?tls_config () = 168 (* First establish the tunnel *) 169 let tunnel_flow = connect ~sw ~net ~proxy ~target_host ~target_port () in 170 171 (* Get or create TLS config *) 172 let tls_config = 173 match tls_config with 174 | Some cfg -> cfg 175 | None -> default_tls_config ~target_host 176 in 177 178 (* Perform TLS handshake through the tunnel *) 179 let host = 180 match Domain_name.of_string target_host with 181 | Ok domain -> ( 182 match Domain_name.host domain with 183 | Ok host -> host 184 | Error (`Msg msg) -> 185 raise 186 (Error.tls_handshake_failedf ~host:target_host 187 "Invalid hostname for SNI: %s" msg)) 188 | Error (`Msg msg) -> 189 raise 190 (Error.tls_handshake_failedf ~host:target_host 191 "Invalid domain name: %s" msg) 192 in 193 194 Log.debug (fun m -> 195 m "Starting TLS handshake with %s through tunnel" target_host); 196 197 try 198 let tls_flow = Tls_eio.client_of_flow tls_config ~host tunnel_flow in 199 Log.info (fun m -> 200 m "TLS tunnel established to %s via proxy %s:%d" target_host 201 proxy.Proxy.host proxy.Proxy.port); 202 (tls_flow :> Eio.Flow.two_way_ty Eio.Resource.t) 203 with exn -> 204 raise 205 (Error.tls_handshake_failedf ~host:target_host "%s" 206 (Printexc.to_string exn))