···6060(* Logging setup *)
6161(* Setup logging using Logs_cli for standard logging options *)
6262let setup_log app_name =
6363- let setup style_renderer level verbose_http =
6363+ let setup style_renderer level verbose_http_ws =
6464 Fmt_tty.setup_std_outputs ?style_renderer ();
6565 Logs.set_level level;
6666 Logs.set_reporter (Logs_fmt.reporter ());
6767- Requests.Cmd.setup_log_sources ~verbose_http level
6767+ (* Extract value from with_source wrapper *)
6868+ Requests.Cmd.setup_log_sources ~verbose_http:verbose_http_ws.Requests.Cmd.value level
6869 in
6970 Term.(const setup $ Fmt_cli.style_renderer () $ Logs_cli.level () $
7071 Requests.Cmd.verbose_http_term app_name)
···301302302303(* Main entry point *)
303304let main method_ urls headers data json_data output include_headers head
304304- auth show_progress persist_cookies verify_tls
305305- timeout follow_redirects max_redirects () =
305305+ auth show_progress persist_cookies_ws verify_tls_ws
306306+ timeout_ws follow_redirects_ws max_redirects_ws () =
307307+308308+ (* Extract values from with_source wrappers *)
309309+ let persist_cookies = persist_cookies_ws.Requests.Cmd.value in
310310+ let verify_tls = verify_tls_ws.Requests.Cmd.value in
311311+ let timeout = timeout_ws.Requests.Cmd.value in
312312+ let follow_redirects = follow_redirects_ws.Requests.Cmd.value in
313313+ let max_redirects = max_redirects_ws.Requests.Cmd.value in
306314307315 Eio_main.run @@ fun env ->
308316 Mirage_crypto_rng_unix.use_default ();
+81
lib/http_write.ml
···187187 let data = Write.serialize_to_string w in
188188 if String.length data > 0 then
189189 Eio.Flow.copy_string data flow
190190+191191+(** {1 Proxy Request Writing} *)
192192+193193+(** Write request line using absolute-URI form for proxy requests.
194194+ Per RFC 9112 Section 3.2.2 *)
195195+let request_line_absolute w ~method_ ~uri =
196196+ Write.string w method_;
197197+ sp w;
198198+ (* Use full absolute URI *)
199199+ Write.string w (Uri.to_string uri);
200200+ Write.string w " HTTP/1.1";
201201+ crlf w
202202+203203+(** Write request headers for proxy request with absolute-URI *)
204204+let request_headers_proxy w ~method_ ~uri ~headers:hdrs ~content_length ~proxy_auth =
205205+ (* Write request line with absolute URI *)
206206+ request_line_absolute w ~method_ ~uri;
207207+208208+ (* Ensure Host header is present *)
209209+ let hdrs = if not (Headers.mem "host" hdrs) then
210210+ Headers.add "host" (host_value uri) hdrs
211211+ else hdrs in
212212+213213+ (* Ensure Connection header for keep-alive *)
214214+ let hdrs = if not (Headers.mem "connection" hdrs) then
215215+ Headers.add "connection" "keep-alive" hdrs
216216+ else hdrs in
217217+218218+ (* Add Content-Length if we have a body length *)
219219+ let hdrs = match content_length with
220220+ | Some len when len > 0L && not (Headers.mem "content-length" hdrs) ->
221221+ Headers.add "content-length" (Int64.to_string len) hdrs
222222+ | _ -> hdrs
223223+ in
224224+225225+ (* Add Proxy-Authorization if configured *)
226226+ let hdrs = match proxy_auth with
227227+ | Some auth ->
228228+ let auth_headers = Auth.apply auth Headers.empty in
229229+ (match Headers.get "authorization" auth_headers with
230230+ | Some value -> Headers.add "proxy-authorization" value hdrs
231231+ | None -> hdrs)
232232+ | None -> hdrs
233233+ in
234234+235235+ (* Write all headers *)
236236+ headers w hdrs
237237+238238+(** Write complete HTTP request via proxy using absolute-URI form *)
239239+let request_via_proxy w ~sw ~method_ ~uri ~headers:hdrs ~body ~proxy_auth =
240240+ let method_str = Method.to_string method_ in
241241+242242+ (* Get content type and length from body *)
243243+ let content_type = Body.content_type body in
244244+ let content_length = Body.content_length body in
245245+246246+ (* Add Content-Type header if body has one *)
247247+ let hdrs = match content_type with
248248+ | Some mime when not (Headers.mem "content-type" hdrs) ->
249249+ Headers.add "content-type" (Mime.to_string mime) hdrs
250250+ | _ -> hdrs
251251+ in
252252+253253+ (* Determine if we need chunked encoding *)
254254+ let use_chunked = Body.Private.is_chunked body in
255255+256256+ let hdrs = if use_chunked && not (Headers.mem "transfer-encoding" hdrs) then
257257+ Headers.add "transfer-encoding" "chunked" hdrs
258258+ else hdrs in
259259+260260+ (* Write request line and headers *)
261261+ request_headers_proxy w ~method_:method_str ~uri ~headers:hdrs
262262+ ~content_length ~proxy_auth;
263263+264264+ (* Write body *)
265265+ if Body.Private.is_empty body then
266266+ ()
267267+ else if use_chunked then
268268+ Body.Private.write_chunked ~sw w body
269269+ else
270270+ Body.Private.write ~sw w body
+25
lib/http_write.mli
···9999 Unlike {!with_flow}, this does not create a nested switch and is safe
100100 to use in complex fiber hierarchies. The tradeoff is that the entire
101101 request is buffered in memory before being written. *)
102102+103103+(** {1 Proxy Request Writing} *)
104104+105105+val request_line_absolute : Eio.Buf_write.t -> method_:string -> uri:Uri.t -> unit
106106+(** [request_line_absolute w ~method_ ~uri] writes an HTTP request line
107107+ using absolute-URI form for proxy requests.
108108+ Per RFC 9112 Section 3.2.2: "A client MUST send a request-line with
109109+ absolute-form as the request-target when making a request to a proxy."
110110+ For example: "GET http://www.example.com/path HTTP/1.1\r\n" *)
111111+112112+val request_via_proxy : Eio.Buf_write.t -> sw:Eio.Switch.t -> method_:Method.t ->
113113+ uri:Uri.t -> headers:Headers.t -> body:Body.t ->
114114+ proxy_auth:Auth.t option -> unit
115115+(** [request_via_proxy w ~sw ~method_ ~uri ~headers ~body ~proxy_auth]
116116+ writes a complete HTTP request using absolute-URI form for proxying.
117117+118118+ Per RFC 9112 Section 3.2.2, when sending a request to a proxy for an
119119+ HTTP URL (not HTTPS), the client MUST use the absolute-URI form:
120120+ {v
121121+ GET http://www.example.com/path HTTP/1.1
122122+ Host: www.example.com
123123+ Proxy-Authorization: Basic ...
124124+ v}
125125+126126+ @param proxy_auth Optional proxy authentication to add as Proxy-Authorization header *)
+102-27
lib/one.ml
···171171 ?(min_tls_version = TLS_1_2)
172172 ?(expect_100_continue = true) ?(expect_100_continue_threshold = 1_048_576L)
173173 ?(allow_insecure_auth = false)
174174+ ?proxy
174175 ~method_ url =
175176176177 let start_time = Unix.gettimeofday () in
···218219 let rec make_with_redirects ~headers_for_request url_to_fetch redirects_left =
219220 let uri_to_fetch = Uri.of_string url_to_fetch in
220221221221- (* Connect to URL (opens new TCP connection) *)
222222- let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
223223- ~timeout ~verify_tls ~tls_config ~min_tls_version in
222222+ (* Determine if we should use proxy for this URL *)
223223+ let use_proxy = match proxy with
224224+ | None -> false
225225+ | Some p -> not (Proxy.should_bypass p url_to_fetch)
226226+ in
227227+228228+ let is_https = Uri.scheme uri_to_fetch = Some "https" in
224229225230 (* Build expect_100 config *)
226231 let expect_100_config = Expect_continue.make
···230235 ()
231236 in
232237233233- (* Make HTTP request using low-level client with 100-continue and optional auto-decompression *)
238238+ (* Connect and make request based on proxy configuration *)
234239 let status, resp_headers, response_body_str =
235235- Http_client.make_request_100_continue_decompress
236236- ~expect_100:expect_100_config
237237- ~clock
238238- ~sw
239239- ~method_ ~uri:uri_to_fetch
240240- ~headers:headers_for_request ~body:request_body
241241- ~auto_decompress flow
240240+ match use_proxy, is_https, proxy with
241241+ | false, _, _ ->
242242+ (* Direct connection *)
243243+ let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
244244+ ~timeout ~verify_tls ~tls_config ~min_tls_version in
245245+ Http_client.make_request_100_continue_decompress
246246+ ~expect_100:expect_100_config
247247+ ~clock
248248+ ~sw
249249+ ~method_ ~uri:uri_to_fetch
250250+ ~headers:headers_for_request ~body:request_body
251251+ ~auto_decompress flow
252252+253253+ | true, false, Some p ->
254254+ (* HTTP via proxy - use absolute-URI form *)
255255+ Log.debug (fun m -> m "[One] Routing HTTP request via proxy %s:%d"
256256+ p.Proxy.host p.Proxy.port);
257257+ let flow = connect_tcp ~sw ~net ~host:p.Proxy.host ~port:p.Proxy.port in
258258+ let flow = (flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t) in
259259+ (* Write request using absolute-URI form *)
260260+ Http_write.write_and_flush flow (fun w ->
261261+ Http_write.request_via_proxy w ~sw ~method_ ~uri:uri_to_fetch
262262+ ~headers:headers_for_request ~body:request_body
263263+ ~proxy_auth:p.Proxy.auth
264264+ );
265265+ (* Read response *)
266266+ let limits = Response_limits.default in
267267+ let buf_read = Http_read.of_flow ~max_size:65536 flow in
268268+ let (_version, status, resp_headers, body_str) =
269269+ Http_read.response ~limits buf_read in
270270+ (* Handle decompression if enabled *)
271271+ let body_str =
272272+ if auto_decompress then
273273+ match Headers.get "content-encoding" resp_headers with
274274+ | Some encoding ->
275275+ Http_client.decompress_body
276276+ ~limits:Response_limits.default
277277+ ~content_encoding:encoding body_str
278278+ | None -> body_str
279279+ else body_str
280280+ in
281281+ (status, resp_headers, body_str)
282282+283283+ | true, true, Some p ->
284284+ (* HTTPS via proxy - establish CONNECT tunnel then TLS *)
285285+ Log.debug (fun m -> m "[One] Routing HTTPS request via proxy %s:%d (CONNECT tunnel)"
286286+ p.Proxy.host p.Proxy.port);
287287+ let target_host = Uri.host uri_to_fetch |> Option.value ~default:"" in
288288+ let target_port = Uri.port uri_to_fetch |> Option.value ~default:443 in
289289+ (* Establish TLS tunnel through proxy *)
290290+ let tunnel_flow = Proxy_tunnel.connect_with_tls
291291+ ~sw ~net ~clock
292292+ ~proxy:p
293293+ ~target_host
294294+ ~target_port
295295+ ?tls_config
296296+ ()
297297+ in
298298+ Http_client.make_request_100_continue_decompress
299299+ ~expect_100:expect_100_config
300300+ ~clock
301301+ ~sw
302302+ ~method_ ~uri:uri_to_fetch
303303+ ~headers:headers_for_request ~body:request_body
304304+ ~auto_decompress tunnel_flow
305305+306306+ | true, _, None ->
307307+ (* Should not happen due to use_proxy check *)
308308+ let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
309309+ ~timeout ~verify_tls ~tls_config ~min_tls_version in
310310+ Http_client.make_request_100_continue_decompress
311311+ ~expect_100:expect_100_config
312312+ ~clock
313313+ ~sw
314314+ ~method_ ~uri:uri_to_fetch
315315+ ~headers:headers_for_request ~body:request_body
316316+ ~auto_decompress flow
242317 in
243318244319 Log.info (fun m -> m "Received response: status=%d" status);
···296371(* Convenience methods *)
297372let get ~sw ~clock ~net ?headers ?auth ?timeout
298373 ?follow_redirects ?max_redirects ?verify_tls ?tls_config ?min_tls_version
299299- ?allow_insecure_auth url =
374374+ ?allow_insecure_auth ?proxy url =
300375 request ~sw ~clock ~net ?headers ?auth ?timeout
301376 ?follow_redirects ?max_redirects ?verify_tls ?tls_config ?min_tls_version
302302- ?allow_insecure_auth
377377+ ?allow_insecure_auth ?proxy
303378 ~expect_100_continue:false (* GET has no body *)
304379 ~method_:`GET url
305380306381let post ~sw ~clock ~net ?headers ?body ?auth ?timeout
307382 ?verify_tls ?tls_config ?min_tls_version
308383 ?expect_100_continue ?expect_100_continue_threshold
309309- ?allow_insecure_auth url =
384384+ ?allow_insecure_auth ?proxy url =
310385 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
311386 ?verify_tls ?tls_config ?min_tls_version
312387 ?expect_100_continue ?expect_100_continue_threshold
313313- ?allow_insecure_auth ~method_:`POST url
388388+ ?allow_insecure_auth ?proxy ~method_:`POST url
314389315390let put ~sw ~clock ~net ?headers ?body ?auth ?timeout
316391 ?verify_tls ?tls_config ?min_tls_version
317392 ?expect_100_continue ?expect_100_continue_threshold
318318- ?allow_insecure_auth url =
393393+ ?allow_insecure_auth ?proxy url =
319394 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
320395 ?verify_tls ?tls_config ?min_tls_version
321396 ?expect_100_continue ?expect_100_continue_threshold
322322- ?allow_insecure_auth ~method_:`PUT url
397397+ ?allow_insecure_auth ?proxy ~method_:`PUT url
323398324399let delete ~sw ~clock ~net ?headers ?auth ?timeout
325400 ?verify_tls ?tls_config ?min_tls_version
326326- ?allow_insecure_auth url =
401401+ ?allow_insecure_auth ?proxy url =
327402 request ~sw ~clock ~net ?headers ?auth ?timeout
328403 ?verify_tls ?tls_config ?min_tls_version
329329- ?allow_insecure_auth
404404+ ?allow_insecure_auth ?proxy
330405 ~expect_100_continue:false (* DELETE typically has no body *)
331406 ~method_:`DELETE url
332407333408let head ~sw ~clock ~net ?headers ?auth ?timeout
334409 ?verify_tls ?tls_config ?min_tls_version
335335- ?allow_insecure_auth url =
410410+ ?allow_insecure_auth ?proxy url =
336411 request ~sw ~clock ~net ?headers ?auth ?timeout
337412 ?verify_tls ?tls_config ?min_tls_version
338338- ?allow_insecure_auth
413413+ ?allow_insecure_auth ?proxy
339414 ~expect_100_continue:false (* HEAD has no body *)
340415 ~method_:`HEAD url
341416342417let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout
343418 ?verify_tls ?tls_config ?min_tls_version
344419 ?expect_100_continue ?expect_100_continue_threshold
345345- ?allow_insecure_auth url =
420420+ ?allow_insecure_auth ?proxy url =
346421 request ~sw ~clock ~net ?headers ?body ?auth ?timeout
347422 ?verify_tls ?tls_config ?min_tls_version
348423 ?expect_100_continue ?expect_100_continue_threshold
349349- ?allow_insecure_auth ~method_:`PATCH url
424424+ ?allow_insecure_auth ?proxy ~method_:`PATCH url
350425351426let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length
352427 ?on_progress ?verify_tls ?tls_config ?min_tls_version
353428 ?(expect_100_continue = true) ?expect_100_continue_threshold
354354- ?allow_insecure_auth ~source url =
429429+ ?allow_insecure_auth ?proxy ~source url =
355430 let method_ = Option.value method_ ~default:`POST in
356431 let mime = Option.value mime ~default:Mime.octet_stream in
357432···369444 let body = Body.of_stream ?length mime tracked_source in
370445 request ~sw ~clock ~net ?headers ~body ?auth ?timeout
371446 ?verify_tls ?tls_config ?min_tls_version
372372- ?allow_insecure_auth
447447+ ?allow_insecure_auth ?proxy
373448 ~expect_100_continue ?expect_100_continue_threshold ~method_ url
374449375450let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress
376376- ?verify_tls ?tls_config ?min_tls_version ?allow_insecure_auth url ~sink =
451451+ ?verify_tls ?tls_config ?min_tls_version ?allow_insecure_auth ?proxy url ~sink =
377452 let response = get ~sw ~clock ~net ?headers ?auth ?timeout
378453 ?verify_tls ?tls_config ?min_tls_version
379379- ?allow_insecure_auth url in
454454+ ?allow_insecure_auth ?proxy url in
380455381456 try
382457 (* Get content length for progress tracking *)
+12
lib/one.mli
···7676 ?expect_100_continue:bool ->
7777 ?expect_100_continue_threshold:int64 ->
7878 ?allow_insecure_auth:bool ->
7979+ ?proxy:Proxy.config ->
7980 method_:Method.t ->
8081 string ->
8182 Response.t
···106107 @param allow_insecure_auth Allow Basic/Bearer/Digest auth over HTTP (default: false).
107108 Per RFC 7617 Section 4 and RFC 6750 Section 5.1, these auth methods
108109 MUST be used over TLS. Set to [true] only for testing environments.
110110+ @param proxy HTTP/HTTPS proxy configuration. When set, requests are routed through the proxy.
111111+ HTTP requests use absolute-URI form (RFC 9112 Section 3.2.2).
112112+ HTTPS requests use CONNECT tunneling (RFC 9110 Section 9.3.6).
109113 @param method_ HTTP method (GET, POST, etc.)
110114 @param url URL to request
111115*)
···123127 ?tls_config:Tls.Config.client ->
124128 ?min_tls_version:tls_version ->
125129 ?allow_insecure_auth:bool ->
130130+ ?proxy:Proxy.config ->
126131 string ->
127132 Response.t
128133(** GET request. See {!request} for parameter details. *)
···141146 ?expect_100_continue:bool ->
142147 ?expect_100_continue_threshold:int64 ->
143148 ?allow_insecure_auth:bool ->
149149+ ?proxy:Proxy.config ->
144150 string ->
145151 Response.t
146152(** POST request with 100-continue support. See {!request} for parameter details. *)
···159165 ?expect_100_continue:bool ->
160166 ?expect_100_continue_threshold:int64 ->
161167 ?allow_insecure_auth:bool ->
168168+ ?proxy:Proxy.config ->
162169 string ->
163170 Response.t
164171(** PUT request with 100-continue support. See {!request} for parameter details. *)
···174181 ?tls_config:Tls.Config.client ->
175182 ?min_tls_version:tls_version ->
176183 ?allow_insecure_auth:bool ->
184184+ ?proxy:Proxy.config ->
177185 string ->
178186 Response.t
179187(** DELETE request. See {!request} for parameter details. *)
···189197 ?tls_config:Tls.Config.client ->
190198 ?min_tls_version:tls_version ->
191199 ?allow_insecure_auth:bool ->
200200+ ?proxy:Proxy.config ->
192201 string ->
193202 Response.t
194203(** HEAD request. See {!request} for parameter details. *)
···207216 ?expect_100_continue:bool ->
208217 ?expect_100_continue_threshold:int64 ->
209218 ?allow_insecure_auth:bool ->
219219+ ?proxy:Proxy.config ->
210220 string ->
211221 Response.t
212222(** PATCH request with 100-continue support. See {!request} for parameter details. *)
···228238 ?expect_100_continue:bool ->
229239 ?expect_100_continue_threshold:int64 ->
230240 ?allow_insecure_auth:bool ->
241241+ ?proxy:Proxy.config ->
231242 source:Eio.Flow.source_ty Eio.Resource.t ->
232243 string ->
233244 Response.t
···246257 ?tls_config:Tls.Config.client ->
247258 ?min_tls_version:tls_version ->
248259 ?allow_insecure_auth:bool ->
260260+ ?proxy:Proxy.config ->
249261 string ->
250262 sink:Eio.Flow.sink_ty Eio.Resource.t ->
251263 unit
+205
lib/proxy.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP Proxy Configuration
77+88+ Per RFC 9110 Section 3.7 and Section 7.3.2:
99+ A proxy is a message-forwarding agent chosen by the client,
1010+ usually configured via local rules. *)
1111+1212+let src = Logs.Src.create "requests.proxy" ~doc:"HTTP Proxy Support"
1313+module Log = (val Logs.src_log src : Logs.LOG)
1414+1515+(** {1 Proxy Types} *)
1616+1717+type proxy_type =
1818+ | HTTP
1919+ | SOCKS5
2020+2121+type config = {
2222+ host : string;
2323+ port : int;
2424+ proxy_type : proxy_type;
2525+ auth : Auth.t option;
2626+ no_proxy : string list;
2727+}
2828+2929+(** {1 Configuration Constructors} *)
3030+3131+let http ?(port = 8080) ?auth ?(no_proxy = []) host =
3232+ Log.debug (fun m -> m "Creating HTTP proxy config: %s:%d" host port);
3333+ { host; port; proxy_type = HTTP; auth; no_proxy }
3434+3535+let socks5 ?(port = 1080) ?auth ?(no_proxy = []) host =
3636+ Log.debug (fun m -> m "Creating SOCKS5 proxy config: %s:%d" host port);
3737+ { host; port; proxy_type = SOCKS5; auth; no_proxy }
3838+3939+(** {1 Configuration Utilities} *)
4040+4141+let should_bypass config url =
4242+ let uri = Uri.of_string url in
4343+ let target_host = Uri.host uri |> Option.value ~default:"" in
4444+ let target_host_lower = String.lowercase_ascii target_host in
4545+4646+ let matches_pattern pattern =
4747+ let pattern_lower = String.lowercase_ascii (String.trim pattern) in
4848+ if String.length pattern_lower = 0 then
4949+ false
5050+ else if pattern_lower.[0] = '*' then
5151+ (* Wildcard pattern: *.example.com matches foo.example.com *)
5252+ let suffix = String.sub pattern_lower 1 (String.length pattern_lower - 1) in
5353+ String.length target_host_lower >= String.length suffix &&
5454+ String.sub target_host_lower
5555+ (String.length target_host_lower - String.length suffix)
5656+ (String.length suffix) = suffix
5757+ else if pattern_lower.[0] = '.' then
5858+ (* .example.com matches example.com and foo.example.com *)
5959+ target_host_lower = String.sub pattern_lower 1 (String.length pattern_lower - 1) ||
6060+ (String.length target_host_lower > String.length pattern_lower &&
6161+ String.sub target_host_lower
6262+ (String.length target_host_lower - String.length pattern_lower)
6363+ (String.length pattern_lower) = pattern_lower)
6464+ else
6565+ (* Exact match *)
6666+ target_host_lower = pattern_lower
6767+ in
6868+6969+ let bypassed = List.exists matches_pattern config.no_proxy in
7070+ if bypassed then
7171+ Log.debug (fun m -> m "URL %s bypasses proxy (matches no_proxy pattern)"
7272+ (Error.sanitize_url url));
7373+ bypassed
7474+7575+let host_port config = (config.host, config.port)
7676+7777+(** {1 Environment Variable Support} *)
7878+7979+let get_env key =
8080+ try Some (Sys.getenv key) with Not_found -> None
8181+8282+let get_env_insensitive key =
8383+ match get_env key with
8484+ | Some v -> Some v
8585+ | None -> get_env (String.lowercase_ascii key)
8686+8787+let parse_no_proxy () =
8888+ let no_proxy_str =
8989+ match get_env "NO_PROXY" with
9090+ | Some v -> v
9191+ | None ->
9292+ match get_env "no_proxy" with
9393+ | Some v -> v
9494+ | None -> ""
9595+ in
9696+ no_proxy_str
9797+ |> String.split_on_char ','
9898+ |> List.map String.trim
9999+ |> List.filter (fun s -> String.length s > 0)
100100+101101+let parse_proxy_url url =
102102+ let uri = Uri.of_string url in
103103+ let host = Uri.host uri |> Option.value ~default:"localhost" in
104104+ let port = Uri.port uri |> Option.value ~default:8080 in
105105+ let auth = match Uri.userinfo uri with
106106+ | Some info ->
107107+ (match String.index_opt info ':' with
108108+ | Some idx ->
109109+ let username = String.sub info 0 idx in
110110+ let password = String.sub info (idx + 1) (String.length info - idx - 1) in
111111+ Some (Auth.basic ~username ~password)
112112+ | None ->
113113+ (* Username only, no password *)
114114+ Some (Auth.basic ~username:info ~password:""))
115115+ | None -> None
116116+ in
117117+ (host, port, auth)
118118+119119+let from_env () =
120120+ let no_proxy = parse_no_proxy () in
121121+ let proxy_url =
122122+ match get_env_insensitive "HTTP_PROXY" with
123123+ | Some url -> Some url
124124+ | None ->
125125+ match get_env_insensitive "HTTPS_PROXY" with
126126+ | Some url -> Some url
127127+ | None -> get_env_insensitive "ALL_PROXY"
128128+ in
129129+ match proxy_url with
130130+ | Some url ->
131131+ let (host, port, auth) = parse_proxy_url url in
132132+ Log.info (fun m -> m "Proxy configured from environment: %s:%d" host port);
133133+ Some { host; port; proxy_type = HTTP; auth; no_proxy }
134134+ | None ->
135135+ Log.debug (fun m -> m "No proxy configured in environment");
136136+ None
137137+138138+let from_env_for_url url =
139139+ let uri = Uri.of_string url in
140140+ let is_https = Uri.scheme uri = Some "https" in
141141+ let no_proxy = parse_no_proxy () in
142142+143143+ (* Check if URL should bypass proxy *)
144144+ let target_host = Uri.host uri |> Option.value ~default:"" in
145145+ let should_bypass_url =
146146+ let target_host_lower = String.lowercase_ascii target_host in
147147+ List.exists (fun pattern ->
148148+ let pattern_lower = String.lowercase_ascii (String.trim pattern) in
149149+ if String.length pattern_lower = 0 then false
150150+ else if pattern_lower.[0] = '*' then
151151+ let suffix = String.sub pattern_lower 1 (String.length pattern_lower - 1) in
152152+ String.length target_host_lower >= String.length suffix &&
153153+ String.sub target_host_lower
154154+ (String.length target_host_lower - String.length suffix)
155155+ (String.length suffix) = suffix
156156+ else if pattern_lower.[0] = '.' then
157157+ target_host_lower = String.sub pattern_lower 1 (String.length pattern_lower - 1) ||
158158+ (String.length target_host_lower > String.length pattern_lower &&
159159+ String.sub target_host_lower
160160+ (String.length target_host_lower - String.length pattern_lower)
161161+ (String.length pattern_lower) = pattern_lower)
162162+ else
163163+ target_host_lower = pattern_lower
164164+ ) no_proxy
165165+ in
166166+167167+ if should_bypass_url then begin
168168+ Log.debug (fun m -> m "URL %s bypasses proxy (matches NO_PROXY)"
169169+ (Error.sanitize_url url));
170170+ None
171171+ end
172172+ else
173173+ let proxy_url =
174174+ if is_https then
175175+ match get_env_insensitive "HTTPS_PROXY" with
176176+ | Some url -> Some url
177177+ | None -> get_env_insensitive "ALL_PROXY"
178178+ else
179179+ match get_env_insensitive "HTTP_PROXY" with
180180+ | Some url -> Some url
181181+ | None -> get_env_insensitive "ALL_PROXY"
182182+ in
183183+ match proxy_url with
184184+ | Some purl ->
185185+ let (host, port, auth) = parse_proxy_url purl in
186186+ Log.debug (fun m -> m "Using proxy %s:%d for URL %s"
187187+ host port (Error.sanitize_url url));
188188+ Some { host; port; proxy_type = HTTP; auth; no_proxy }
189189+ | None -> None
190190+191191+(** {1 Pretty Printing} *)
192192+193193+let pp_proxy_type ppf = function
194194+ | HTTP -> Format.fprintf ppf "HTTP"
195195+ | SOCKS5 -> Format.fprintf ppf "SOCKS5"
196196+197197+let pp_config ppf config =
198198+ Format.fprintf ppf "@[<v>Proxy Configuration:@,";
199199+ Format.fprintf ppf " Type: %a@," pp_proxy_type config.proxy_type;
200200+ Format.fprintf ppf " Host: %s@," config.host;
201201+ Format.fprintf ppf " Port: %d@," config.port;
202202+ Format.fprintf ppf " Auth: %s@,"
203203+ (if Option.is_some config.auth then "[CONFIGURED]" else "None");
204204+ Format.fprintf ppf " No-proxy: [%s]@]"
205205+ (String.concat ", " config.no_proxy)
+128
lib/proxy.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP Proxy Configuration
77+88+ Per RFC 9110 Section 3.7 and Section 7.3.2:
99+ A proxy is a message-forwarding agent chosen by the client,
1010+ usually configured via local rules.
1111+1212+ {2 Usage}
1313+1414+ Create a proxy configuration:
1515+ {[
1616+ let proxy = Proxy.http ~port:8080 "proxy.example.com"
1717+1818+ (* With authentication *)
1919+ let proxy = Proxy.http
2020+ ~port:8080
2121+ ~auth:(Auth.basic ~username:"user" ~password:"pass")
2222+ "proxy.example.com"
2323+2424+ (* With bypass list *)
2525+ let proxy = Proxy.http
2626+ ~no_proxy:["localhost"; "*.internal.example.com"]
2727+ "proxy.example.com"
2828+ ]}
2929+3030+ Read from environment variables:
3131+ {[
3232+ match Proxy.from_env () with
3333+ | Some proxy -> (* use proxy *)
3434+ | None -> (* no proxy configured *)
3535+ ]} *)
3636+3737+(** Log source for proxy operations *)
3838+val src : Logs.Src.t
3939+4040+(** {1 Proxy Types} *)
4141+4242+(** Proxy protocol type *)
4343+type proxy_type =
4444+ | HTTP (** HTTP proxy (CONNECT for HTTPS, absolute-URI for HTTP) *)
4545+ | SOCKS5 (** SOCKS5 proxy (RFC 1928) - future extension *)
4646+4747+(** Proxy configuration *)
4848+type config = {
4949+ host : string; (** Proxy server hostname *)
5050+ port : int; (** Proxy server port (default: 8080) *)
5151+ proxy_type : proxy_type;
5252+ auth : Auth.t option; (** Proxy authentication (Proxy-Authorization) *)
5353+ no_proxy : string list; (** Hosts/patterns to bypass proxy *)
5454+}
5555+5656+(** {1 Configuration Constructors} *)
5757+5858+val http : ?port:int -> ?auth:Auth.t -> ?no_proxy:string list -> string -> config
5959+(** [http ?port ?auth ?no_proxy host] creates an HTTP proxy configuration.
6060+6161+ @param port Proxy port (default: 8080)
6262+ @param auth Proxy authentication credentials
6363+ @param no_proxy List of hosts/patterns to bypass the proxy.
6464+ Supports wildcards like [*.example.com] to match [foo.example.com].
6565+ @param host Proxy server hostname *)
6666+6767+val socks5 : ?port:int -> ?auth:Auth.t -> ?no_proxy:string list -> string -> config
6868+(** [socks5 ?port ?auth ?no_proxy host] creates a SOCKS5 proxy configuration.
6969+7070+ {b Note:} SOCKS5 support is not yet implemented. This function creates
7171+ the configuration type for future use.
7272+7373+ @param port Proxy port (default: 1080)
7474+ @param auth Proxy authentication credentials
7575+ @param no_proxy List of hosts/patterns to bypass the proxy
7676+ @param host Proxy server hostname *)
7777+7878+(** {1 Configuration Utilities} *)
7979+8080+val should_bypass : config -> string -> bool
8181+(** [should_bypass config url] returns [true] if [url] should bypass
8282+ the proxy based on the [no_proxy] list.
8383+8484+ Matching rules:
8585+ - Exact hostname match (case-insensitive)
8686+ - Wildcard prefix match: [*.example.com] matches [foo.example.com]
8787+ - [localhost] and [127.0.0.1] match by default if in no_proxy list *)
8888+8989+val host_port : config -> string * int
9090+(** [host_port config] returns the proxy host and port as a tuple. *)
9191+9292+(** {1 Environment Variable Support} *)
9393+9494+val from_env : unit -> config option
9595+(** [from_env ()] reads proxy configuration from environment variables.
9696+9797+ Checks the following variables (in order of preference):
9898+ - [HTTP_PROXY] / [http_proxy]
9999+ - [HTTPS_PROXY] / [https_proxy]
100100+ - [ALL_PROXY] / [all_proxy]
101101+ - [NO_PROXY] / [no_proxy] (comma-separated list of bypass patterns)
102102+103103+ Returns [None] if no proxy is configured.
104104+105105+ URL format: [http://[user:pass@]host[:port]]
106106+107107+ Example environment:
108108+ {[
109109+ HTTP_PROXY=http://user:pass@proxy.example.com:8080
110110+ NO_PROXY=localhost,*.internal.example.com,.example.org
111111+ ]} *)
112112+113113+val from_env_for_url : string -> config option
114114+(** [from_env_for_url url] reads proxy configuration appropriate for [url].
115115+116116+ - Uses [HTTPS_PROXY] for HTTPS URLs
117117+ - Uses [HTTP_PROXY] for HTTP URLs
118118+ - Falls back to [ALL_PROXY]
119119+ - Returns [None] if the URL matches [NO_PROXY] patterns *)
120120+121121+(** {1 Pretty Printing} *)
122122+123123+val pp_proxy_type : Format.formatter -> proxy_type -> unit
124124+(** Pretty printer for proxy type *)
125125+126126+val pp_config : Format.formatter -> config -> unit
127127+(** Pretty printer for proxy configuration.
128128+ Note: Authentication credentials are redacted. *)
+196
lib/proxy_tunnel.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP CONNECT Tunneling for HTTPS via Proxy
77+88+ Per RFC 9110 Section 9.3.6:
99+ The CONNECT method requests that the recipient establish a tunnel
1010+ to the destination origin server and, if successful, thereafter restrict
1111+ its behavior to blind forwarding of packets in both directions. *)
1212+1313+let src = Logs.Src.create "requests.proxy_tunnel" ~doc:"HTTPS proxy tunneling"
1414+module Log = (val Logs.src_log src : Logs.LOG)
1515+1616+module Write = Eio.Buf_write
1717+module Read = Eio.Buf_read
1818+1919+(** {1 Low-level Functions} *)
2020+2121+let write_connect_request w ~proxy ~target_host ~target_port =
2222+ let target = Printf.sprintf "%s:%d" target_host target_port in
2323+2424+ (* CONNECT request line per RFC 9110 Section 9.3.6 *)
2525+ Write.string w "CONNECT ";
2626+ Write.string w target;
2727+ Write.string w " HTTP/1.1\r\n";
2828+2929+ (* Host header is required *)
3030+ Write.string w "Host: ";
3131+ Write.string w target;
3232+ Write.string w "\r\n";
3333+3434+ (* Proxy-Authorization if configured *)
3535+ (match proxy.Proxy.auth with
3636+ | Some auth ->
3737+ (* Apply auth to get the Authorization header, then rename to Proxy-Authorization *)
3838+ let headers = Auth.apply auth Headers.empty in
3939+ (match Headers.get "authorization" headers with
4040+ | Some value ->
4141+ Write.string w "Proxy-Authorization: ";
4242+ Write.string w value;
4343+ Write.string w "\r\n"
4444+ | None -> ())
4545+ | None -> ());
4646+4747+ (* User-Agent for debugging *)
4848+ Write.string w "User-Agent: ocaml-requests\r\n";
4949+5050+ (* End of headers *)
5151+ Write.string w "\r\n";
5252+5353+ Log.debug (fun m -> m "Wrote CONNECT request for %s via %s:%d"
5454+ target proxy.Proxy.host proxy.Proxy.port)
5555+5656+let parse_connect_response r ~proxy ~target =
5757+ (* Parse status line - we just need version and status code *)
5858+ let version_str = Read.take_while (function
5959+ | 'A'..'Z' | 'a'..'z' | '0'..'9' | '/' | '.' -> true
6060+ | _ -> false) r
6161+ in
6262+ Read.char ' ' r;
6363+ let status_str = Read.take_while (function '0'..'9' -> true | _ -> false) r in
6464+ Read.char ' ' r;
6565+ let reason = Read.line r in
6666+6767+ let status =
6868+ try int_of_string status_str
6969+ with _ ->
7070+ raise (Error.err (Error.Proxy_error {
7171+ host = proxy.Proxy.host;
7272+ reason = Printf.sprintf "Invalid status code in CONNECT response: %s" status_str
7373+ }))
7474+ in
7575+7676+ Log.debug (fun m -> m "CONNECT response: %s %d %s" version_str status reason);
7777+7878+ (* Read headers until empty line *)
7979+ let rec skip_headers () =
8080+ let line = Read.line r in
8181+ if line <> "" then skip_headers ()
8282+ in
8383+ skip_headers ();
8484+8585+ (* Check for success (2xx) *)
8686+ if status < 200 || status >= 300 then
8787+ raise (Error.err (Error.Proxy_error {
8888+ host = proxy.Proxy.host;
8989+ reason = Printf.sprintf "CONNECT to %s failed: %d %s" target status reason
9090+ }));
9191+9292+ Log.info (fun m -> m "CONNECT tunnel established to %s via proxy %s:%d"
9393+ target proxy.Proxy.host proxy.Proxy.port)
9494+9595+(** {1 Tunnel Establishment} *)
9696+9797+let connect ~sw ~net ~proxy ~target_host ~target_port () =
9898+ let target = Printf.sprintf "%s:%d" target_host target_port in
9999+100100+ Log.debug (fun m -> m "Establishing CONNECT tunnel to %s via %s:%d"
101101+ target proxy.Proxy.host proxy.Proxy.port);
102102+103103+ (* Connect to proxy server *)
104104+ let proxy_addr =
105105+ let addrs = Eio.Net.getaddrinfo_stream net proxy.Proxy.host
106106+ ~service:(string_of_int proxy.Proxy.port)
107107+ in
108108+ match addrs with
109109+ | [] ->
110110+ raise (Error.err (Error.Dns_resolution_failed {
111111+ hostname = proxy.Proxy.host
112112+ }))
113113+ | addr :: _ -> addr
114114+ in
115115+116116+ let flow =
117117+ try
118118+ Eio.Net.connect ~sw net proxy_addr
119119+ with exn ->
120120+ raise (Error.err (Error.Tcp_connect_failed {
121121+ host = proxy.Proxy.host;
122122+ port = proxy.Proxy.port;
123123+ reason = Printexc.to_string exn
124124+ }))
125125+ in
126126+127127+ Log.debug (fun m -> m "Connected to proxy %s:%d" proxy.Proxy.host proxy.Proxy.port);
128128+129129+ (* Send CONNECT request *)
130130+ Http_write.write_and_flush flow (fun w ->
131131+ write_connect_request w ~proxy ~target_host ~target_port
132132+ );
133133+134134+ (* Read and validate response *)
135135+ let buf_read = Read.of_flow ~max_size:65536 flow in
136136+ parse_connect_response buf_read ~proxy ~target;
137137+138138+ (* Return the raw flow - caller is responsible for TLS wrapping *)
139139+ (flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
140140+141141+let connect_with_tls ~sw ~net ~clock:_ ~proxy ~target_host ~target_port
142142+ ?tls_config () =
143143+ (* First establish the tunnel *)
144144+ let tunnel_flow = connect ~sw ~net ~proxy ~target_host ~target_port () in
145145+146146+ (* Get or create TLS config *)
147147+ let tls_config = match tls_config with
148148+ | Some cfg -> cfg
149149+ | None ->
150150+ (* Use system CA certificates *)
151151+ let authenticator =
152152+ match Ca_certs.authenticator () with
153153+ | Ok auth -> auth
154154+ | Error (`Msg msg) ->
155155+ Log.warn (fun m -> m "Failed to load CA certificates: %s, using null authenticator" msg);
156156+ fun ?ip:_ ~host:_ _ -> Ok None
157157+ in
158158+ match Tls.Config.client ~authenticator () with
159159+ | Ok cfg -> cfg
160160+ | Error (`Msg msg) ->
161161+ raise (Error.err (Error.Tls_handshake_failed {
162162+ host = target_host;
163163+ reason = "TLS config error: " ^ msg
164164+ }))
165165+ in
166166+167167+ (* Perform TLS handshake through the tunnel *)
168168+ let host =
169169+ match Domain_name.of_string target_host with
170170+ | Ok domain ->
171171+ (match Domain_name.host domain with
172172+ | Ok host -> host
173173+ | Error (`Msg msg) ->
174174+ raise (Error.err (Error.Tls_handshake_failed {
175175+ host = target_host;
176176+ reason = Printf.sprintf "Invalid hostname for SNI: %s" msg
177177+ })))
178178+ | Error (`Msg msg) ->
179179+ raise (Error.err (Error.Tls_handshake_failed {
180180+ host = target_host;
181181+ reason = Printf.sprintf "Invalid domain name: %s" msg
182182+ }))
183183+ in
184184+185185+ Log.debug (fun m -> m "Starting TLS handshake with %s through tunnel" target_host);
186186+187187+ try
188188+ let tls_flow = Tls_eio.client_of_flow tls_config ~host tunnel_flow in
189189+ Log.info (fun m -> m "TLS tunnel established to %s via proxy %s:%d"
190190+ target_host proxy.Proxy.host proxy.Proxy.port);
191191+ (tls_flow :> Eio.Flow.two_way_ty Eio.Resource.t)
192192+ with exn ->
193193+ raise (Error.err (Error.Tls_handshake_failed {
194194+ host = target_host;
195195+ reason = Printexc.to_string exn
196196+ }))
+116
lib/proxy_tunnel.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP CONNECT Tunneling for HTTPS via Proxy
77+88+ Per RFC 9110 Section 9.3.6:
99+ The CONNECT method requests that the recipient establish a tunnel
1010+ to the destination origin server and, if successful, thereafter restrict
1111+ its behavior to blind forwarding of packets in both directions.
1212+1313+ {2 Usage}
1414+1515+ Establish an HTTPS tunnel through an HTTP proxy:
1616+ {[
1717+ let tunnel_flow = Proxy_tunnel.connect
1818+ ~sw ~net
1919+ ~proxy:(Proxy.http "proxy.example.com")
2020+ ~target_host:"api.example.com"
2121+ ~target_port:443
2222+ ()
2323+ in
2424+ (* Now wrap tunnel_flow with TLS and send HTTPS requests *)
2525+ ]} *)
2626+2727+(** Log source for tunnel operations *)
2828+val src : Logs.Src.t
2929+3030+(** {1 Tunnel Establishment} *)
3131+3232+val connect :
3333+ sw:Eio.Switch.t ->
3434+ net:_ Eio.Net.t ->
3535+ proxy:Proxy.config ->
3636+ target_host:string ->
3737+ target_port:int ->
3838+ unit ->
3939+ [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t
4040+(** [connect ~sw ~net ~proxy ~target_host ~target_port ()] establishes
4141+ an HTTP tunnel through [proxy] to [target_host:target_port].
4242+4343+ This performs the following steps per RFC 9110 Section 9.3.6:
4444+ 1. Opens a TCP connection to the proxy server
4545+ 2. Sends a CONNECT request with the target host:port
4646+ 3. Includes Proxy-Authorization header if proxy has auth configured
4747+ 4. Waits for a 2xx response from the proxy
4848+ 5. Returns the raw connection for the caller to wrap with TLS
4949+5050+ @param sw Eio switch for resource management
5151+ @param net Eio network capability
5252+ @param proxy Proxy configuration including host, port, and optional auth
5353+ @param target_host Destination server hostname
5454+ @param target_port Destination server port (typically 443 for HTTPS)
5555+ @raise Error.Proxy_error if the CONNECT request fails
5656+ @raise Error.Tcp_connect_failed if cannot connect to proxy *)
5757+5858+val connect_with_tls :
5959+ sw:Eio.Switch.t ->
6060+ net:_ Eio.Net.t ->
6161+ clock:_ Eio.Time.clock ->
6262+ proxy:Proxy.config ->
6363+ target_host:string ->
6464+ target_port:int ->
6565+ ?tls_config:Tls.Config.client ->
6666+ unit ->
6767+ Eio.Flow.two_way_ty Eio.Resource.t
6868+(** [connect_with_tls ~sw ~net ~clock ~proxy ~target_host ~target_port ?tls_config ()]
6969+ establishes an HTTPS tunnel and performs TLS handshake.
7070+7171+ This is a convenience function that combines {!connect} with TLS wrapping:
7272+ 1. Establishes the tunnel via {!connect}
7373+ 2. Performs TLS handshake with the target host through the tunnel
7474+ 3. Returns the TLS-wrapped connection ready for HTTPS requests
7575+7676+ @param sw Eio switch for resource management
7777+ @param net Eio network capability
7878+ @param clock Eio clock for TLS operations
7979+ @param proxy Proxy configuration
8080+ @param target_host Destination server hostname (used for SNI)
8181+ @param target_port Destination server port
8282+ @param tls_config Optional custom TLS configuration. If not provided,
8383+ uses default configuration from system CA certificates.
8484+ @raise Error.Proxy_error if tunnel establishment fails
8585+ @raise Error.Tls_handshake_failed if TLS handshake fails *)
8686+8787+(** {1 Low-level Functions} *)
8888+8989+val write_connect_request :
9090+ Eio.Buf_write.t ->
9191+ proxy:Proxy.config ->
9292+ target_host:string ->
9393+ target_port:int ->
9494+ unit
9595+(** [write_connect_request w ~proxy ~target_host ~target_port] writes
9696+ a CONNECT request to the buffer.
9797+9898+ Format per RFC 9110 Section 9.3.6:
9999+ {v
100100+ CONNECT host:port HTTP/1.1
101101+ Host: host:port
102102+ Proxy-Authorization: Basic ... (if auth configured)
103103+104104+ v}
105105+106106+ This is exposed for testing and custom tunnel implementations. *)
107107+108108+val parse_connect_response :
109109+ Eio.Buf_read.t ->
110110+ proxy:Proxy.config ->
111111+ target:string ->
112112+ unit
113113+(** [parse_connect_response r ~proxy ~target] reads and validates
114114+ the CONNECT response from the proxy.
115115+116116+ @raise Error.Proxy_error if the response status is not 2xx *)
+379-63
lib/requests.ml
···1313module Headers = Headers
1414module Http_date = Http_date
1515module Auth = Auth
1616+module Proxy = Proxy
1717+module Proxy_tunnel = Proxy_tunnel
1618module Timeout = Timeout
1719module Body = Body
1820module Response = Response
···6870 base_url : string option; (** Per Recommendation #11: Base URL for relative paths *)
6971 xsrf_cookie_name : string option; (** Per Recommendation #24: XSRF cookie name *)
7072 xsrf_header_name : string; (** Per Recommendation #24: XSRF header name *)
7373+ proxy : Proxy.config option; (** HTTP/HTTPS proxy configuration *)
71747275 (* Statistics - mutable but NOTE: when sessions are derived via record update
7376 syntax ({t with field = value}), these are copied not shared. Each derived
···103106 ?base_url
104107 ?(xsrf_cookie_name = Some "XSRF-TOKEN") (* Per Recommendation #24 *)
105108 ?(xsrf_header_name = "X-XSRF-TOKEN")
109109+ ?proxy
106110 env =
107111108112 let clock = env#clock in
···226230 base_url;
227231 xsrf_cookie_name;
228232 xsrf_header_name;
233233+ proxy;
229234 requests_made = 0;
230235 total_time = 0.0;
231236 retries_count = 0;
···255260256261let cookies (T t) = t.cookie_jar
257262let clear_cookies (T t) = Cookeio_jar.clear t.cookie_jar
263263+264264+let set_proxy (T t) config =
265265+ Log.debug (fun m -> m "Setting proxy: %s:%d" config.Proxy.host config.Proxy.port);
266266+ T { t with proxy = Some config }
267267+268268+let clear_proxy (T t) =
269269+ Log.debug (fun m -> m "Clearing proxy configuration");
270270+ T { t with proxy = None }
271271+272272+let proxy (T t) = t.proxy
258273259274(* Helper to check if two URIs have the same origin for security purposes.
260275 Used to determine if sensitive headers (Authorization, Cookie) should be
···550565 );
551566 Log.info (fun m -> m "");
552567568568+ (* Determine if we should use proxy for this URL *)
569569+ let use_proxy = match t.proxy with
570570+ | None -> false
571571+ | Some proxy -> not (Proxy.should_bypass proxy url_to_fetch)
572572+ in
573573+553574 let make_request_fn () =
554554- Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
555555- (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
556556- (* Use our low-level HTTP client with 100-continue support and optional auto-decompression *)
557557- Http_client.make_request_100_continue_decompress
558558- ~expect_100:t.expect_100_continue
559559- ~clock:t.clock
560560- ~sw:t.sw
561561- ~method_ ~uri:uri_to_fetch
562562- ~headers:headers_with_cookies ~body:request_body
563563- ~auto_decompress:t.auto_decompress flow
564564- )
575575+ match use_proxy, redirect_is_https, t.proxy with
576576+ | false, _, _ ->
577577+ (* Direct connection - use connection pool *)
578578+ Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
579579+ Http_client.make_request_100_continue_decompress
580580+ ~expect_100:t.expect_100_continue
581581+ ~clock:t.clock
582582+ ~sw:t.sw
583583+ ~method_ ~uri:uri_to_fetch
584584+ ~headers:headers_with_cookies ~body:request_body
585585+ ~auto_decompress:t.auto_decompress flow
586586+ )
587587+588588+ | true, false, Some proxy ->
589589+ (* HTTP via proxy - connect to proxy and use absolute-URI form *)
590590+ Log.debug (fun m -> m "Routing HTTP request via proxy %s:%d"
591591+ proxy.Proxy.host proxy.Proxy.port);
592592+ let proxy_endpoint = Conpool.Endpoint.make
593593+ ~host:proxy.Proxy.host ~port:proxy.Proxy.port in
594594+ Conpool.with_connection t.http_pool proxy_endpoint (fun flow ->
595595+ (* Write request using absolute-URI form *)
596596+ Http_write.write_and_flush flow (fun w ->
597597+ Http_write.request_via_proxy w ~sw:t.sw ~method_ ~uri:uri_to_fetch
598598+ ~headers:headers_with_cookies ~body:request_body
599599+ ~proxy_auth:proxy.Proxy.auth
600600+ );
601601+ (* Read response *)
602602+ let limits = Response_limits.default in
603603+ let buf_read = Http_read.of_flow ~max_size:65536 flow in
604604+ let (_version, status, resp_headers, body_str) =
605605+ Http_read.response ~limits buf_read in
606606+ (* Handle decompression if enabled *)
607607+ let body_str =
608608+ if t.auto_decompress then
609609+ match Headers.get "content-encoding" resp_headers with
610610+ | Some encoding ->
611611+ Http_client.decompress_body
612612+ ~limits:Response_limits.default
613613+ ~content_encoding:encoding body_str
614614+ | None -> body_str
615615+ else body_str
616616+ in
617617+ (status, resp_headers, body_str)
618618+ )
619619+620620+ | true, true, Some proxy ->
621621+ (* HTTPS via proxy - establish CONNECT tunnel then TLS *)
622622+ Log.debug (fun m -> m "Routing HTTPS request via proxy %s:%d (CONNECT tunnel)"
623623+ proxy.Proxy.host proxy.Proxy.port);
624624+ (* Establish TLS tunnel through proxy *)
625625+ let tunnel_flow = Proxy_tunnel.connect_with_tls
626626+ ~sw:t.sw ~net:t.net ~clock:t.clock
627627+ ~proxy
628628+ ~target_host:redirect_host
629629+ ~target_port:redirect_port
630630+ ?tls_config:t.tls_config
631631+ ()
632632+ in
633633+ (* Send request through tunnel using normal format (not absolute-URI) *)
634634+ Http_client.make_request_100_continue_decompress
635635+ ~expect_100:t.expect_100_continue
636636+ ~clock:t.clock
637637+ ~sw:t.sw
638638+ ~method_ ~uri:uri_to_fetch
639639+ ~headers:headers_with_cookies ~body:request_body
640640+ ~auto_decompress:t.auto_decompress tunnel_flow
641641+642642+ | true, _, None ->
643643+ (* Should not happen due to use_proxy check *)
644644+ Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
645645+ Http_client.make_request_100_continue_decompress
646646+ ~expect_100:t.expect_100_continue
647647+ ~clock:t.clock
648648+ ~sw:t.sw
649649+ ~method_ ~uri:uri_to_fetch
650650+ ~headers:headers_with_cookies ~body:request_body
651651+ ~auto_decompress:t.auto_decompress flow
652652+ )
565653 in
566654567655 (* Apply timeout if specified *)
···810898module Cmd = struct
811899 open Cmdliner
812900901901+ (** Source tracking for configuration values.
902902+ Tracks where each configuration value came from for debugging
903903+ and transparency. *)
904904+ type source =
905905+ | Default (** Value from hardcoded default *)
906906+ | Env of string (** Value from environment variable (stores var name) *)
907907+ | Cmdline (** Value from command-line argument *)
908908+909909+ (** Wrapper for values with source tracking *)
910910+ type 'a with_source = {
911911+ value : 'a;
912912+ source : source;
913913+ }
914914+915915+ (** Proxy configuration from command line and environment *)
916916+ type proxy_config = {
917917+ proxy_url : string with_source option; (** Proxy URL (from HTTP_PROXY/HTTPS_PROXY/etc) *)
918918+ no_proxy : string with_source option; (** NO_PROXY patterns *)
919919+ }
920920+813921 type config = {
814922 xdg : Xdge.t * Xdge.Cmd.t;
815815- persist_cookies : bool;
816816- verify_tls : bool;
817817- timeout : float option;
818818- max_retries : int;
819819- retry_backoff : float;
820820- follow_redirects : bool;
821821- max_redirects : int;
822822- user_agent : string option;
823823- verbose_http : bool;
923923+ persist_cookies : bool with_source;
924924+ verify_tls : bool with_source;
925925+ timeout : float option with_source;
926926+ max_retries : int with_source;
927927+ retry_backoff : float with_source;
928928+ follow_redirects : bool with_source;
929929+ max_redirects : int with_source;
930930+ user_agent : string option with_source;
931931+ verbose_http : bool with_source;
932932+ proxy : proxy_config;
824933 }
825934935935+ (** Helper to check environment variable and track source *)
936936+ let check_env_bool ~app_name ~suffix ~default =
937937+ let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
938938+ match Sys.getenv_opt env_var with
939939+ | Some v when String.lowercase_ascii v = "1" || String.lowercase_ascii v = "true" ->
940940+ { value = true; source = Env env_var }
941941+ | Some v when String.lowercase_ascii v = "0" || String.lowercase_ascii v = "false" ->
942942+ { value = false; source = Env env_var }
943943+ | Some _ | None -> { value = default; source = Default }
944944+945945+ let check_env_string ~app_name ~suffix =
946946+ let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
947947+ match Sys.getenv_opt env_var with
948948+ | Some v when v <> "" -> Some { value = v; source = Env env_var }
949949+ | Some _ | None -> None
950950+951951+ let check_env_float ~app_name ~suffix ~default =
952952+ let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
953953+ match Sys.getenv_opt env_var with
954954+ | Some v ->
955955+ (try { value = float_of_string v; source = Env env_var }
956956+ with _ -> { value = default; source = Default })
957957+ | None -> { value = default; source = Default }
958958+959959+ let check_env_int ~app_name ~suffix ~default =
960960+ let env_var = String.uppercase_ascii app_name ^ "_" ^ suffix in
961961+ match Sys.getenv_opt env_var with
962962+ | Some v ->
963963+ (try { value = int_of_string v; source = Env env_var }
964964+ with _ -> { value = default; source = Default })
965965+ | None -> { value = default; source = Default }
966966+967967+ (** Parse proxy configuration from environment.
968968+ Follows standard HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY conventions. *)
969969+ let proxy_from_env () =
970970+ let proxy_url =
971971+ (* Check in order of preference *)
972972+ match Sys.getenv_opt "HTTP_PROXY" with
973973+ | Some v when v <> "" -> Some { value = v; source = Env "HTTP_PROXY" }
974974+ | _ ->
975975+ match Sys.getenv_opt "http_proxy" with
976976+ | Some v when v <> "" -> Some { value = v; source = Env "http_proxy" }
977977+ | _ ->
978978+ match Sys.getenv_opt "HTTPS_PROXY" with
979979+ | Some v when v <> "" -> Some { value = v; source = Env "HTTPS_PROXY" }
980980+ | _ ->
981981+ match Sys.getenv_opt "https_proxy" with
982982+ | Some v when v <> "" -> Some { value = v; source = Env "https_proxy" }
983983+ | _ ->
984984+ match Sys.getenv_opt "ALL_PROXY" with
985985+ | Some v when v <> "" -> Some { value = v; source = Env "ALL_PROXY" }
986986+ | _ ->
987987+ match Sys.getenv_opt "all_proxy" with
988988+ | Some v when v <> "" -> Some { value = v; source = Env "all_proxy" }
989989+ | _ -> None
990990+ in
991991+ let no_proxy =
992992+ match Sys.getenv_opt "NO_PROXY" with
993993+ | Some v when v <> "" -> Some { value = v; source = Env "NO_PROXY" }
994994+ | _ ->
995995+ match Sys.getenv_opt "no_proxy" with
996996+ | Some v when v <> "" -> Some { value = v; source = Env "no_proxy" }
997997+ | _ -> None
998998+ in
999999+ { proxy_url; no_proxy }
10001000+8261001 let create config env sw =
8271002 let xdg, _xdg_cmd = config.xdg in
828828- let retry = if config.max_retries > 0 then
10031003+ let retry = if config.max_retries.value > 0 then
8291004 Some (Retry.create_config
830830- ~max_retries:config.max_retries
831831- ~backoff_factor:config.retry_backoff ())
10051005+ ~max_retries:config.max_retries.value
10061006+ ~backoff_factor:config.retry_backoff.value ())
8321007 else None in
8331008834834- let timeout = match config.timeout with
10091009+ let timeout = match config.timeout.value with
8351010 | Some t -> Timeout.create ~total:t ()
8361011 | None -> Timeout.default in
837101210131013+ (* Build proxy config if URL is set *)
10141014+ let proxy = match config.proxy.proxy_url with
10151015+ | Some { value = url; _ } ->
10161016+ let no_proxy = match config.proxy.no_proxy with
10171017+ | Some { value = np; _ } ->
10181018+ np |> String.split_on_char ','
10191019+ |> List.map String.trim
10201020+ |> List.filter (fun s -> s <> "")
10211021+ | None -> []
10221022+ in
10231023+ (* Parse proxy URL to extract components *)
10241024+ let uri = Uri.of_string url in
10251025+ let host = Uri.host uri |> Option.value ~default:"localhost" in
10261026+ let port = Uri.port uri |> Option.value ~default:8080 in
10271027+ let auth = match Uri.userinfo uri with
10281028+ | Some info ->
10291029+ (match String.index_opt info ':' with
10301030+ | Some idx ->
10311031+ let username = String.sub info 0 idx in
10321032+ let password = String.sub info (idx + 1) (String.length info - idx - 1) in
10331033+ Some (Auth.basic ~username ~password)
10341034+ | None -> Some (Auth.basic ~username:info ~password:""))
10351035+ | None -> None
10361036+ in
10371037+ Some (Proxy.http ~port ?auth ~no_proxy host)
10381038+ | None -> None
10391039+ in
10401040+8381041 let req = create ~sw
8391042 ~xdg
840840- ~persist_cookies:config.persist_cookies
841841- ~verify_tls:config.verify_tls
10431043+ ~persist_cookies:config.persist_cookies.value
10441044+ ~verify_tls:config.verify_tls.value
8421045 ~timeout
8431046 ?retry
844844- ~follow_redirects:config.follow_redirects
845845- ~max_redirects:config.max_redirects
10471047+ ~follow_redirects:config.follow_redirects.value
10481048+ ~max_redirects:config.max_redirects.value
10491049+ ?proxy
8461050 env in
84710518481052 (* Set user agent if provided *)
849849- let req = match config.user_agent with
10531053+ let req = match config.user_agent.value with
8501054 | Some ua -> set_default_header req "User-Agent" ua
8511055 | None -> req
8521056 in
85310578541058 req
8551059856856- (* Individual terms - parameterized by app_name *)
10601060+ (* Individual terms - parameterized by app_name
10611061+ These terms return with_source wrapped values to track provenance *)
85710628581063 let persist_cookies_term app_name =
8591064 let doc = "Persist cookies to disk between sessions" in
8601065 let env_name = String.uppercase_ascii app_name ^ "_PERSIST_COOKIES" in
8611066 let env_info = Cmdliner.Cmd.Env.info env_name in
862862- Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc)
10671067+ let cmdline_arg = Arg.(value & flag & info ["persist-cookies"] ~env:env_info ~doc) in
10681068+ Term.(const (fun cmdline ->
10691069+ if cmdline then
10701070+ { value = true; source = Cmdline }
10711071+ else
10721072+ check_env_bool ~app_name ~suffix:"PERSIST_COOKIES" ~default:false
10731073+ ) $ cmdline_arg)
86310748641075 let verify_tls_term app_name =
8651076 let doc = "Skip TLS certificate verification (insecure)" in
8661077 let env_name = String.uppercase_ascii app_name ^ "_NO_VERIFY_TLS" in
8671078 let env_info = Cmdliner.Cmd.Env.info env_name in
868868- Term.(const (fun no_verify -> not no_verify) $
869869- Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc))
10791079+ let cmdline_arg = Arg.(value & flag & info ["no-verify-tls"] ~env:env_info ~doc) in
10801080+ Term.(const (fun no_verify ->
10811081+ if no_verify then
10821082+ { value = false; source = Cmdline }
10831083+ else
10841084+ let env_val = check_env_bool ~app_name ~suffix:"NO_VERIFY_TLS" ~default:false in
10851085+ { value = not env_val.value; source = env_val.source }
10861086+ ) $ cmdline_arg)
87010878711088 let timeout_term app_name =
8721089 let doc = "Request timeout in seconds" in
8731090 let env_name = String.uppercase_ascii app_name ^ "_TIMEOUT" in
8741091 let env_info = Cmdliner.Cmd.Env.info env_name in
875875- Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc)
10921092+ let cmdline_arg = Arg.(value & opt (some float) None & info ["timeout"] ~env:env_info ~docv:"SECONDS" ~doc) in
10931093+ Term.(const (fun cmdline ->
10941094+ match cmdline with
10951095+ | Some t -> { value = Some t; source = Cmdline }
10961096+ | None ->
10971097+ match check_env_string ~app_name ~suffix:"TIMEOUT" with
10981098+ | Some { value = v; source } ->
10991099+ (try { value = Some (float_of_string v); source }
11001100+ with _ -> { value = None; source = Default })
11011101+ | None -> { value = None; source = Default }
11021102+ ) $ cmdline_arg)
87611038771104 let retries_term app_name =
8781105 let doc = "Maximum number of request retries" in
8791106 let env_name = String.uppercase_ascii app_name ^ "_MAX_RETRIES" in
8801107 let env_info = Cmdliner.Cmd.Env.info env_name in
881881- Arg.(value & opt int 3 & info ["max-retries"] ~env:env_info ~docv:"N" ~doc)
11081108+ let cmdline_arg = Arg.(value & opt (some int) None & info ["max-retries"] ~env:env_info ~docv:"N" ~doc) in
11091109+ Term.(const (fun cmdline ->
11101110+ match cmdline with
11111111+ | Some n -> { value = n; source = Cmdline }
11121112+ | None -> check_env_int ~app_name ~suffix:"MAX_RETRIES" ~default:3
11131113+ ) $ cmdline_arg)
88211148831115 let retry_backoff_term app_name =
8841116 let doc = "Retry backoff factor for exponential delay" in
8851117 let env_name = String.uppercase_ascii app_name ^ "_RETRY_BACKOFF" in
8861118 let env_info = Cmdliner.Cmd.Env.info env_name in
887887- Arg.(value & opt float 0.3 & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc)
11191119+ let cmdline_arg = Arg.(value & opt (some float) None & info ["retry-backoff"] ~env:env_info ~docv:"FACTOR" ~doc) in
11201120+ Term.(const (fun cmdline ->
11211121+ match cmdline with
11221122+ | Some f -> { value = f; source = Cmdline }
11231123+ | None -> check_env_float ~app_name ~suffix:"RETRY_BACKOFF" ~default:0.3
11241124+ ) $ cmdline_arg)
88811258891126 let follow_redirects_term app_name =
8901127 let doc = "Don't follow HTTP redirects" in
8911128 let env_name = String.uppercase_ascii app_name ^ "_NO_FOLLOW_REDIRECTS" in
8921129 let env_info = Cmdliner.Cmd.Env.info env_name in
893893- Term.(const (fun no_follow -> not no_follow) $
894894- Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc))
11301130+ let cmdline_arg = Arg.(value & flag & info ["no-follow-redirects"] ~env:env_info ~doc) in
11311131+ Term.(const (fun no_follow ->
11321132+ if no_follow then
11331133+ { value = false; source = Cmdline }
11341134+ else
11351135+ let env_val = check_env_bool ~app_name ~suffix:"NO_FOLLOW_REDIRECTS" ~default:false in
11361136+ { value = not env_val.value; source = env_val.source }
11371137+ ) $ cmdline_arg)
89511388961139 let max_redirects_term app_name =
8971140 let doc = "Maximum number of redirects to follow" in
8981141 let env_name = String.uppercase_ascii app_name ^ "_MAX_REDIRECTS" in
8991142 let env_info = Cmdliner.Cmd.Env.info env_name in
900900- Arg.(value & opt int 10 & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc)
11431143+ let cmdline_arg = Arg.(value & opt (some int) None & info ["max-redirects"] ~env:env_info ~docv:"N" ~doc) in
11441144+ Term.(const (fun cmdline ->
11451145+ match cmdline with
11461146+ | Some n -> { value = n; source = Cmdline }
11471147+ | None -> check_env_int ~app_name ~suffix:"MAX_REDIRECTS" ~default:10
11481148+ ) $ cmdline_arg)
90111499021150 let user_agent_term app_name =
9031151 let doc = "User-Agent header to send with requests" in
9041152 let env_name = String.uppercase_ascii app_name ^ "_USER_AGENT" in
9051153 let env_info = Cmdliner.Cmd.Env.info env_name in
906906- Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc)
11541154+ let cmdline_arg = Arg.(value & opt (some string) None & info ["user-agent"] ~env:env_info ~docv:"STRING" ~doc) in
11551155+ Term.(const (fun cmdline ->
11561156+ match cmdline with
11571157+ | Some ua -> { value = Some ua; source = Cmdline }
11581158+ | None ->
11591159+ match check_env_string ~app_name ~suffix:"USER_AGENT" with
11601160+ | Some { value; source } -> { value = Some value; source }
11611161+ | None -> { value = None; source = Default }
11621162+ ) $ cmdline_arg)
90711639081164 let verbose_http_term app_name =
9091165 let doc = "Enable verbose HTTP-level logging (hexdumps, TLS details)" in
9101166 let env_name = String.uppercase_ascii app_name ^ "_VERBOSE_HTTP" in
9111167 let env_info = Cmdliner.Cmd.Env.info env_name in
912912- Arg.(value & flag & info ["verbose-http"] ~env:env_info ~doc)
11681168+ let cmdline_arg = Arg.(value & flag & info ["verbose-http"] ~env:env_info ~doc) in
11691169+ Term.(const (fun cmdline ->
11701170+ if cmdline then
11711171+ { value = true; source = Cmdline }
11721172+ else
11731173+ check_env_bool ~app_name ~suffix:"VERBOSE_HTTP" ~default:false
11741174+ ) $ cmdline_arg)
11751175+11761176+ let proxy_term _app_name =
11771177+ let doc = "HTTP/HTTPS proxy URL (e.g., http://proxy:8080)" in
11781178+ let cmdline_arg = Arg.(value & opt (some string) None & info ["proxy"] ~docv:"URL" ~doc) in
11791179+ let no_proxy_doc = "Comma-separated list of hosts to bypass proxy" in
11801180+ let no_proxy_arg = Arg.(value & opt (some string) None & info ["no-proxy"] ~docv:"HOSTS" ~doc:no_proxy_doc) in
11811181+ Term.(const (fun cmdline_proxy cmdline_no_proxy ->
11821182+ let proxy_url = match cmdline_proxy with
11831183+ | Some url -> Some { value = url; source = Cmdline }
11841184+ | None -> (proxy_from_env ()).proxy_url
11851185+ in
11861186+ let no_proxy = match cmdline_no_proxy with
11871187+ | Some np -> Some { value = np; source = Cmdline }
11881188+ | None -> (proxy_from_env ()).no_proxy
11891189+ in
11901190+ { proxy_url; no_proxy }
11911191+ ) $ cmdline_arg $ no_proxy_arg)
91311929141193 (* Combined terms *)
91511949161195 let config_term app_name fs =
9171196 let xdg_term = Xdge.Cmd.term app_name fs
9181197 ~dirs:[`Config; `Data; `Cache] () in
919919- Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua verbose ->
11981198+ Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua verbose proxy ->
9201199 { xdg; persist_cookies = persist; verify_tls = verify;
9211200 timeout; max_retries = retries; retry_backoff = backoff;
9221201 follow_redirects = follow; max_redirects = max_redir;
923923- user_agent = ua; verbose_http = verbose })
12021202+ user_agent = ua; verbose_http = verbose; proxy })
9241203 $ xdg_term
9251204 $ persist_cookies_term app_name
9261205 $ verify_tls_term app_name
···9301209 $ follow_redirects_term app_name
9311210 $ max_redirects_term app_name
9321211 $ user_agent_term app_name
933933- $ verbose_http_term app_name)
12121212+ $ verbose_http_term app_name
12131213+ $ proxy_term app_name)
93412149351215 let requests_term app_name eio_env sw =
9361216 let config_t = config_term app_name eio_env#fs in
···9391219 let minimal_term app_name fs =
9401220 let xdg_term = Xdge.Cmd.term app_name fs
9411221 ~dirs:[`Data; `Cache] () in
942942- Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist))
12221222+ Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist.value))
9431223 $ xdg_term
9441224 $ persist_cookies_term app_name)
9451225···9481228 Printf.sprintf
9491229 "## ENVIRONMENT\n\n\
9501230 The following environment variables affect %s:\n\n\
12311231+ ### XDG Directories\n\n\
9511232 **%s_CONFIG_DIR**\n\
9521233 : Override configuration directory location\n\n\
9531234 **%s_DATA_DIR**\n\
···9601241 : Base directory for user data files (default: ~/.local/share)\n\n\
9611242 **XDG_CACHE_HOME**\n\
9621243 : Base directory for user cache files (default: ~/.cache)\n\n\
12441244+ ### HTTP Settings\n\n\
9631245 **%s_PERSIST_COOKIES**\n\
9641246 : Set to '1' to persist cookies by default\n\n\
9651247 **%s_NO_VERIFY_TLS**\n\
···9771259 **%s_USER_AGENT**\n\
9781260 : User-Agent header to send with requests\n\n\
9791261 **%s_VERBOSE_HTTP**\n\
980980- : Set to '1' to enable verbose HTTP-level logging\
12621262+ : Set to '1' to enable verbose HTTP-level logging\n\n\
12631263+ ### Proxy Configuration\n\n\
12641264+ **HTTP_PROXY** / **http_proxy**\n\
12651265+ : HTTP proxy URL (e.g., http://proxy:8080 or http://user:pass@proxy:8080)\n\n\
12661266+ **HTTPS_PROXY** / **https_proxy**\n\
12671267+ : HTTPS proxy URL (used for HTTPS requests)\n\n\
12681268+ **ALL_PROXY** / **all_proxy**\n\
12691269+ : Fallback proxy URL for all protocols\n\n\
12701270+ **NO_PROXY** / **no_proxy**\n\
12711271+ : Comma-separated list of hosts to bypass proxy (e.g., localhost,*.example.com)\
9811272 "
9821273 app_name app_upper app_upper app_upper
9831274 app_upper app_upper app_upper app_upper
9841275 app_upper app_upper app_upper app_upper app_upper
9851276986986- let pp_config ppf config =
12771277+ (** Pretty-print source type *)
12781278+ let pp_source ppf = function
12791279+ | Default -> Format.fprintf ppf "default"
12801280+ | Env var -> Format.fprintf ppf "env(%s)" var
12811281+ | Cmdline -> Format.fprintf ppf "cmdline"
12821282+12831283+ (** Pretty-print a value with its source *)
12841284+ let pp_with_source pp_val ppf ws =
12851285+ Format.fprintf ppf "%a [%a]" pp_val ws.value pp_source ws.source
12861286+12871287+ let pp_config ?(show_sources = true) ppf config =
9871288 let _xdg, xdg_cmd = config.xdg in
12891289+ let pp_bool = Format.pp_print_bool in
12901290+ let pp_float = Format.pp_print_float in
12911291+ let pp_int = Format.pp_print_int in
12921292+ let pp_string_opt = Format.pp_print_option Format.pp_print_string in
12931293+ let pp_float_opt = Format.pp_print_option Format.pp_print_float in
12941294+12951295+ let pp_val pp = if show_sources then pp_with_source pp else fun ppf ws -> pp ppf ws.value in
12961296+9881297 Format.fprintf ppf "@[<v>Configuration:@,\
9891298 @[<v 2>XDG:@,%a@]@,\
990990- persist_cookies: %b@,\
991991- verify_tls: %b@,\
12991299+ persist_cookies: %a@,\
13001300+ verify_tls: %a@,\
9921301 timeout: %a@,\
993993- max_retries: %d@,\
994994- retry_backoff: %.2f@,\
995995- follow_redirects: %b@,\
996996- max_redirects: %d@,\
13021302+ max_retries: %a@,\
13031303+ retry_backoff: %a@,\
13041304+ follow_redirects: %a@,\
13051305+ max_redirects: %a@,\
9971306 user_agent: %a@,\
998998- verbose_http: %b@]"
13071307+ verbose_http: %a@,\
13081308+ @[<v 2>Proxy:@,\
13091309+ url: %a@,\
13101310+ no_proxy: %a@]@]"
9991311 Xdge.Cmd.pp xdg_cmd
10001000- config.persist_cookies
10011001- config.verify_tls
10021002- (Format.pp_print_option Format.pp_print_float) config.timeout
10031003- config.max_retries
10041004- config.retry_backoff
10051005- config.follow_redirects
10061006- config.max_redirects
10071007- (Format.pp_print_option Format.pp_print_string) config.user_agent
10081008- config.verbose_http
13121312+ (pp_val pp_bool) config.persist_cookies
13131313+ (pp_val pp_bool) config.verify_tls
13141314+ (pp_val pp_float_opt) config.timeout
13151315+ (pp_val pp_int) config.max_retries
13161316+ (pp_val pp_float) config.retry_backoff
13171317+ (pp_val pp_bool) config.follow_redirects
13181318+ (pp_val pp_int) config.max_redirects
13191319+ (pp_val pp_string_opt) config.user_agent
13201320+ (pp_val pp_bool) config.verbose_http
13211321+ (Format.pp_print_option (pp_with_source Format.pp_print_string))
13221322+ config.proxy.proxy_url
13231323+ (Format.pp_print_option (pp_with_source Format.pp_print_string))
13241324+ config.proxy.no_proxy
1009132510101326 (* Logging configuration *)
10111327 let setup_log_sources ?(verbose_http = false) level =
+172-39
lib/requests.mli
···243243 ?base_url:string ->
244244 ?xsrf_cookie_name:string option ->
245245 ?xsrf_header_name:string ->
246246+ ?proxy:Proxy.config ->
246247 < clock: _ Eio.Time.clock; net: _ Eio.Net.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
247248 t
248249(** Create a new requests instance with persistent state and connection pooling.
···272273 @param base_url Base URL for relative paths (per Recommendation #11). Relative URLs are resolved against this.
273274 @param xsrf_cookie_name Cookie name to extract XSRF token from (default: Some "XSRF-TOKEN", per Recommendation #24). Set to None to disable.
274275 @param xsrf_header_name Header name to inject XSRF token into (default: "X-XSRF-TOKEN")
276276+ @param proxy HTTP/HTTPS proxy configuration. When set, requests are routed through the proxy.
277277+ HTTP requests use absolute-URI form (RFC 9112 Section 3.2.2).
278278+ HTTPS requests use CONNECT tunneling (RFC 9110 Section 9.3.6).
275279276280 {b Note:} HTTP caching has been disabled for simplicity. See CACHEIO.md for integration notes
277281 if you need to restore caching functionality in the future.
···475479val clear_cookies : t -> unit
476480(** Clear all cookies *)
477481482482+(** {2 Proxy Configuration} *)
483483+484484+val set_proxy : t -> Proxy.config -> t
485485+(** Set HTTP/HTTPS proxy configuration. Returns a new session with proxy configured.
486486+ When set, requests are routed through the proxy:
487487+ - HTTP requests use absolute-URI form (RFC 9112 Section 3.2.2)
488488+ - HTTPS requests use CONNECT tunneling (RFC 9110 Section 9.3.6)
489489+490490+ Example:
491491+ {[
492492+ let proxy = Proxy.http ~port:8080 "proxy.example.com" in
493493+ let session = Requests.set_proxy session proxy
494494+ ]} *)
495495+496496+val clear_proxy : t -> t
497497+(** Remove proxy configuration. Returns a new session without proxy. *)
498498+499499+val proxy : t -> Proxy.config option
500500+(** Get the current proxy configuration, if any. *)
501501+478502(** {1 Cmdliner Integration} *)
479503480504module Cmd : sig
···482506483507 This module provides command-line argument handling for configuring
484508 HTTP requests, including XDG directory paths, timeouts, retries,
485485- and other parameters. *)
509509+ proxy settings, and other parameters.
510510+511511+ {2 Source Tracking}
512512+513513+ Configuration values include source tracking to indicate where
514514+ each value came from (command line, environment variable, or default).
515515+ This enables transparent debugging and helps users understand
516516+ how their configuration was resolved.
517517+518518+ {[
519519+ let config = ... in
520520+ if show_sources then
521521+ Format.printf "%a@." (Cmd.pp_config ~show_sources:true) config
522522+ ]} *)
523523+524524+ (** {2 Source Tracking Types} *)
525525+526526+ (** Source of a configuration value.
527527+ Tracks where each configuration value originated from for debugging
528528+ and transparency. *)
529529+ type source =
530530+ | Default (** Value from hardcoded default *)
531531+ | Env of string (** Value from environment variable (stores var name) *)
532532+ | Cmdline (** Value from command-line argument *)
486533487487- (** Configuration from command line and environment *)
534534+ (** Wrapper for values with source tracking *)
535535+ type 'a with_source = {
536536+ value : 'a; (** The actual configuration value *)
537537+ source : source; (** Where the value came from *)
538538+ }
539539+540540+ (** Proxy configuration from command line and environment *)
541541+ type proxy_config = {
542542+ proxy_url : string with_source option; (** Proxy URL (from HTTP_PROXY/HTTPS_PROXY/etc) *)
543543+ no_proxy : string with_source option; (** NO_PROXY patterns *)
544544+ }
545545+546546+ (** {2 Configuration Type} *)
547547+548548+ (** Configuration from command line and environment.
549549+ All values include source tracking for debugging. *)
488550 type config = {
489489- xdg : Xdge.t * Xdge.Cmd.t; (** XDG paths and their sources *)
490490- persist_cookies : bool; (** Whether to persist cookies *)
491491- verify_tls : bool; (** Whether to verify TLS certificates *)
492492- timeout : float option; (** Request timeout in seconds *)
493493- max_retries : int; (** Maximum number of retries *)
494494- retry_backoff : float; (** Retry backoff factor *)
495495- follow_redirects : bool; (** Whether to follow redirects *)
496496- max_redirects : int; (** Maximum number of redirects *)
497497- user_agent : string option; (** User-Agent header *)
498498- verbose_http : bool; (** Enable verbose HTTP-level logging *)
551551+ xdg : Xdge.t * Xdge.Cmd.t; (** XDG paths and their sources *)
552552+ persist_cookies : bool with_source; (** Whether to persist cookies *)
553553+ verify_tls : bool with_source; (** Whether to verify TLS certificates *)
554554+ timeout : float option with_source; (** Request timeout in seconds *)
555555+ max_retries : int with_source; (** Maximum number of retries *)
556556+ retry_backoff : float with_source; (** Retry backoff factor *)
557557+ follow_redirects : bool with_source; (** Whether to follow redirects *)
558558+ max_redirects : int with_source; (** Maximum number of redirects *)
559559+ user_agent : string option with_source; (** User-Agent header *)
560560+ verbose_http : bool with_source; (** Enable verbose HTTP-level logging *)
561561+ proxy : proxy_config; (** Proxy configuration *)
499562 }
500563501564 val create : config -> < clock: _ Eio.Time.clock; net: _ Eio.Net.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > -> Eio.Switch.t -> t
502502- (** [create config env sw] creates a requests instance from command-line configuration *)
565565+ (** [create config env sw] creates a requests instance from command-line configuration.
566566+ Proxy configuration from the config is applied automatically. *)
503567504504- (** {2 Individual Terms} *)
568568+ (** {2 Individual Terms}
569569+570570+ Each term returns a value with source tracking to indicate whether
571571+ the value came from the command line, environment, or default.
572572+ Source precedence: Cmdline > Env > Default *)
505573506506- val persist_cookies_term : string -> bool Cmdliner.Term.t
507507- (** Term for [--persist-cookies] flag with app-specific env var *)
574574+ val persist_cookies_term : string -> bool with_source Cmdliner.Term.t
575575+ (** Term for [--persist-cookies] flag with app-specific env var.
576576+ Env var: [{APP_NAME}_PERSIST_COOKIES] *)
508577509509- val verify_tls_term : string -> bool Cmdliner.Term.t
510510- (** Term for [--no-verify-tls] flag with app-specific env var *)
578578+ val verify_tls_term : string -> bool with_source Cmdliner.Term.t
579579+ (** Term for [--no-verify-tls] flag with app-specific env var.
580580+ Env var: [{APP_NAME}_NO_VERIFY_TLS] *)
511581512512- val timeout_term : string -> float option Cmdliner.Term.t
513513- (** Term for [--timeout SECONDS] option with app-specific env var *)
582582+ val timeout_term : string -> float option with_source Cmdliner.Term.t
583583+ (** Term for [--timeout SECONDS] option with app-specific env var.
584584+ Env var: [{APP_NAME}_TIMEOUT] *)
514585515515- val retries_term : string -> int Cmdliner.Term.t
516516- (** Term for [--max-retries N] option with app-specific env var *)
586586+ val retries_term : string -> int with_source Cmdliner.Term.t
587587+ (** Term for [--max-retries N] option with app-specific env var.
588588+ Env var: [{APP_NAME}_MAX_RETRIES] *)
517589518518- val retry_backoff_term : string -> float Cmdliner.Term.t
519519- (** Term for [--retry-backoff FACTOR] option with app-specific env var *)
590590+ val retry_backoff_term : string -> float with_source Cmdliner.Term.t
591591+ (** Term for [--retry-backoff FACTOR] option with app-specific env var.
592592+ Env var: [{APP_NAME}_RETRY_BACKOFF] *)
520593521521- val follow_redirects_term : string -> bool Cmdliner.Term.t
522522- (** Term for [--no-follow-redirects] flag with app-specific env var *)
594594+ val follow_redirects_term : string -> bool with_source Cmdliner.Term.t
595595+ (** Term for [--no-follow-redirects] flag with app-specific env var.
596596+ Env var: [{APP_NAME}_NO_FOLLOW_REDIRECTS] *)
523597524524- val max_redirects_term : string -> int Cmdliner.Term.t
525525- (** Term for [--max-redirects N] option with app-specific env var *)
598598+ val max_redirects_term : string -> int with_source Cmdliner.Term.t
599599+ (** Term for [--max-redirects N] option with app-specific env var.
600600+ Env var: [{APP_NAME}_MAX_REDIRECTS] *)
526601527527- val user_agent_term : string -> string option Cmdliner.Term.t
528528- (** Term for [--user-agent STRING] option with app-specific env var *)
602602+ val user_agent_term : string -> string option with_source Cmdliner.Term.t
603603+ (** Term for [--user-agent STRING] option with app-specific env var.
604604+ Env var: [{APP_NAME}_USER_AGENT] *)
529605530530- val verbose_http_term : string -> bool Cmdliner.Term.t
606606+ val verbose_http_term : string -> bool with_source Cmdliner.Term.t
531607 (** Term for [--verbose-http] flag with app-specific env var.
532608533609 Enables verbose HTTP-level logging including hexdumps, TLS details,
534610 and low-level protocol information. Typically used in conjunction
535535- with debug-level logging. *)
611611+ with debug-level logging.
612612+ Env var: [{APP_NAME}_VERBOSE_HTTP] *)
613613+614614+ val proxy_term : string -> proxy_config Cmdliner.Term.t
615615+ (** Term for [--proxy URL] and [--no-proxy HOSTS] options.
616616+617617+ Provides cmdliner integration for proxy configuration with proper
618618+ source tracking. Environment variables are checked in order:
619619+ HTTP_PROXY, http_proxy, HTTPS_PROXY, https_proxy, ALL_PROXY, all_proxy.
620620+621621+ {b Generated Flags:}
622622+ - [--proxy URL]: HTTP/HTTPS proxy URL (e.g., http://proxy:8080)
623623+ - [--no-proxy HOSTS]: Comma-separated list of hosts to bypass proxy
624624+625625+ {b Environment Variables:}
626626+ - [HTTP_PROXY] / [http_proxy]: HTTP proxy URL
627627+ - [HTTPS_PROXY] / [https_proxy]: HTTPS proxy URL
628628+ - [ALL_PROXY] / [all_proxy]: Fallback proxy URL for all protocols
629629+ - [NO_PROXY] / [no_proxy]: Hosts to bypass proxy *)
536630537631 (** {2 Combined Terms} *)
538632···540634 (** [config_term app_name fs] creates a complete configuration term.
541635542636 This combines all individual terms plus XDG configuration into
543543- a single term that can be used to configure requests.
637637+ a single term that can be used to configure requests. All values
638638+ include source tracking.
544639545640 {b Generated Flags:}
546641 - [--config-dir DIR]: Configuration directory
···555650 - [--max-redirects N]: Maximum redirects to follow
556651 - [--user-agent STRING]: User-Agent header
557652 - [--verbose-http]: Enable verbose HTTP-level logging
653653+ - [--proxy URL]: HTTP/HTTPS proxy URL
654654+ - [--no-proxy HOSTS]: Hosts to bypass proxy
558655559656 {b Example:}
560657 {[
···596693 - [--cache-dir DIR]: Cache directory for responses
597694 - [--persist-cookies]: Cookie persistence flag
598695599599- Returns the XDG context and persist_cookies boolean.
696696+ Returns the XDG context and persist_cookies boolean (without source tracking
697697+ for simplified usage).
600698601699 {b Example:}
602700 {[
···611709 Cmd.eval cmd
612710 ]} *)
613711614614- (** {2 Documentation} *)
712712+ (** {2 Documentation and Pretty-Printing} *)
615713616714 val env_docs : string -> string
617715 (** [env_docs app_name] generates environment variable documentation.
618716619717 Returns formatted documentation for all environment variables that
620620- affect requests configuration, including XDG variables.
718718+ affect requests configuration, including XDG variables and proxy settings.
621719622720 {b Included Variables:}
623721 - [${APP_NAME}_CONFIG_DIR]: Configuration directory
···625723 - [${APP_NAME}_CACHE_DIR]: Cache directory
626724 - [${APP_NAME}_STATE_DIR]: State directory
627725 - [XDG_CONFIG_HOME], [XDG_DATA_HOME], [XDG_CACHE_HOME], [XDG_STATE_HOME]
628628- - [HTTP_PROXY], [HTTPS_PROXY], [NO_PROXY] (when proxy support is added)
726726+ - [HTTP_PROXY], [HTTPS_PROXY], [ALL_PROXY]: Proxy URLs
727727+ - [NO_PROXY]: Hosts to bypass proxy
629728630729 {b Example:}
631730 {[
···635734 ()
636735 ]} *)
637736638638- val pp_config : Format.formatter -> config -> unit
639639- (** Pretty print configuration for debugging *)
737737+ val pp_source : Format.formatter -> source -> unit
738738+ (** Pretty print a source type.
739739+ Output format: "default", "env(VAR_NAME)", or "cmdline" *)
740740+741741+ val pp_with_source : (Format.formatter -> 'a -> unit) -> Format.formatter -> 'a with_source -> unit
742742+ (** [pp_with_source pp_val ppf ws] pretty prints a value with its source.
743743+ Output format: "value [source]"
744744+745745+ {b Example:}
746746+ {[
747747+ let pp_bool_with_source = Cmd.pp_with_source Format.pp_print_bool in
748748+ Format.printf "%a@." pp_bool_with_source config.verify_tls
749749+ (* Output: true [env(MYAPP_NO_VERIFY_TLS)] *)
750750+ ]} *)
751751+752752+ val pp_config : ?show_sources:bool -> Format.formatter -> config -> unit
753753+ (** [pp_config ?show_sources ppf config] pretty prints configuration for debugging.
754754+755755+ @param show_sources If true (default), shows the source of each value
756756+ (e.g., "default", "env(VAR_NAME)", "cmdline"). If false, only
757757+ shows the values without source annotations.
758758+759759+ {b Example:}
760760+ {[
761761+ (* Show full config with sources *)
762762+ Format.printf "%a@." (Cmd.pp_config ~show_sources:true) config;
763763+764764+ (* Show config without sources for cleaner output *)
765765+ Format.printf "%a@." (Cmd.pp_config ~show_sources:false) config;
766766+ ]} *)
640767641768 (** {2 Logging Configuration} *)
642769···705832706833(** Authentication schemes (Basic, Bearer, OAuth, etc.) *)
707834module Auth = Auth
835835+836836+(** HTTP/HTTPS proxy configuration *)
837837+module Proxy = Proxy
838838+839839+(** HTTPS proxy tunneling via CONNECT *)
840840+module Proxy_tunnel = Proxy_tunnel
708841709842(** Error types and exception handling *)
710843module Error = Error