A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** HTTP Proxy Configuration
7
8 Per RFC 9110 Section 3.7 and Section 7.3.2:
9 A proxy is a message-forwarding agent chosen by the client,
10 usually configured via local rules. *)
11
12let src = Logs.Src.create "requests.proxy" ~doc:"HTTP Proxy Support"
13module Log = (val Logs.src_log src : Logs.LOG)
14
15(** {1 Proxy Types} *)
16
17type proxy_type =
18 | HTTP
19 | SOCKS5
20
21type config = {
22 host : string;
23 port : int;
24 proxy_type : proxy_type;
25 auth : Auth.t option;
26 no_proxy : string list;
27}
28
29(** {1 Configuration Constructors} *)
30
31let http ?(port = 8080) ?auth ?(no_proxy = []) host =
32 Log.debug (fun m -> m "Creating HTTP proxy config: %s:%d" host port);
33 { host; port; proxy_type = HTTP; auth; no_proxy }
34
35let socks5 ?(port = 1080) ?auth ?(no_proxy = []) host =
36 Log.debug (fun m -> m "Creating SOCKS5 proxy config: %s:%d" host port);
37 { host; port; proxy_type = SOCKS5; auth; no_proxy }
38
39(** {1 Pattern Matching for NO_PROXY} *)
40
41(** Check if a hostname matches a no_proxy pattern.
42 Supports:
43 - Exact match: "example.com"
44 - Wildcard prefix: "*.example.com" matches foo.example.com
45 - Dot prefix: ".example.com" matches example.com and foo.example.com *)
46let host_matches_pattern ~host pattern =
47 let host_lower = String.lowercase_ascii host in
48 let pattern_lower = String.lowercase_ascii (String.trim pattern) in
49 match String.length pattern_lower with
50 | 0 -> false
51 | _ when pattern_lower.[0] = '*' ->
52 (* Wildcard pattern: *.example.com matches foo.example.com *)
53 let suffix = String.sub pattern_lower 1 (String.length pattern_lower - 1) in
54 String.length host_lower >= String.length suffix &&
55 String.sub host_lower
56 (String.length host_lower - String.length suffix)
57 (String.length suffix) = suffix
58 | _ when pattern_lower.[0] = '.' ->
59 (* .example.com matches example.com and foo.example.com *)
60 host_lower = String.sub pattern_lower 1 (String.length pattern_lower - 1) ||
61 (String.length host_lower > String.length pattern_lower &&
62 String.sub host_lower
63 (String.length host_lower - String.length pattern_lower)
64 (String.length pattern_lower) = pattern_lower)
65 | _ ->
66 (* Exact match *)
67 host_lower = pattern_lower
68
69(** {1 Configuration Utilities} *)
70
71let should_bypass config url =
72 let uri = Uri.of_string url in
73 let target_host = Uri.host uri |> Option.value ~default:"" in
74 let bypassed = List.exists (host_matches_pattern ~host:target_host) config.no_proxy in
75 if bypassed then
76 Log.debug (fun m -> m "URL %s bypasses proxy (matches no_proxy pattern)"
77 (Error.sanitize_url url));
78 bypassed
79
80let host_port config = (config.host, config.port)
81
82(** Validate that the proxy type is supported.
83 Currently only HTTP proxies are implemented.
84 @raise Error.Proxy_error if SOCKS5 is requested *)
85let validate_supported config =
86 match config.proxy_type with
87 | HTTP -> ()
88 | SOCKS5 ->
89 Log.err (fun m -> m "SOCKS5 proxy requested but not implemented");
90 raise (Error.err (Error.Proxy_error {
91 host = config.host;
92 reason = "SOCKS5 proxy is not yet implemented"
93 }))
94
95(** {1 Environment Variable Support} *)
96
97let get_env key =
98 try Some (Sys.getenv key) with Not_found -> None
99
100let get_env_insensitive key =
101 match get_env key with
102 | Some v -> Some v
103 | None -> get_env (String.lowercase_ascii key)
104
105let parse_no_proxy () =
106 let no_proxy_str =
107 match get_env "NO_PROXY" with
108 | Some v -> v
109 | None ->
110 match get_env "no_proxy" with
111 | Some v -> v
112 | None -> ""
113 in
114 no_proxy_str
115 |> String.split_on_char ','
116 |> List.map String.trim
117 |> List.filter (fun s -> String.length s > 0)
118
119let parse_proxy_url url =
120 let uri = Uri.of_string url in
121 let host = Uri.host uri |> Option.value ~default:"localhost" in
122 let port = Uri.port uri |> Option.value ~default:8080 in
123 let auth = match Uri.userinfo uri with
124 | Some info ->
125 (match String.index_opt info ':' with
126 | Some idx ->
127 let username = String.sub info 0 idx in
128 let password = String.sub info (idx + 1) (String.length info - idx - 1) in
129 Some (Auth.basic ~username ~password)
130 | None ->
131 (* Username only, no password *)
132 Some (Auth.basic ~username:info ~password:""))
133 | None -> None
134 in
135 (host, port, auth)
136
137let from_env () =
138 let no_proxy = parse_no_proxy () in
139 let proxy_url =
140 match get_env_insensitive "HTTP_PROXY" with
141 | Some url -> Some url
142 | None ->
143 match get_env_insensitive "HTTPS_PROXY" with
144 | Some url -> Some url
145 | None -> get_env_insensitive "ALL_PROXY"
146 in
147 match proxy_url with
148 | Some url ->
149 let (host, port, auth) = parse_proxy_url url in
150 Log.info (fun m -> m "Proxy configured from environment: %s:%d" host port);
151 Some { host; port; proxy_type = HTTP; auth; no_proxy }
152 | None ->
153 Log.debug (fun m -> m "No proxy configured in environment");
154 None
155
156let from_env_for_url url =
157 let uri = Uri.of_string url in
158 let is_https = Uri.scheme uri = Some "https" in
159 let no_proxy = parse_no_proxy () in
160
161 (* Check if URL should bypass proxy *)
162 let target_host = Uri.host uri |> Option.value ~default:"" in
163 let should_bypass_url =
164 List.exists (host_matches_pattern ~host:target_host) no_proxy
165 in
166
167 if should_bypass_url then begin
168 Log.debug (fun m -> m "URL %s bypasses proxy (matches NO_PROXY)"
169 (Error.sanitize_url url));
170 None
171 end
172 else
173 let proxy_url =
174 if is_https then
175 match get_env_insensitive "HTTPS_PROXY" with
176 | Some url -> Some url
177 | None -> get_env_insensitive "ALL_PROXY"
178 else
179 match get_env_insensitive "HTTP_PROXY" with
180 | Some url -> Some url
181 | None -> get_env_insensitive "ALL_PROXY"
182 in
183 match proxy_url with
184 | Some purl ->
185 let (host, port, auth) = parse_proxy_url purl in
186 Log.debug (fun m -> m "Using proxy %s:%d for URL %s"
187 host port (Error.sanitize_url url));
188 Some { host; port; proxy_type = HTTP; auth; no_proxy }
189 | None -> None
190
191(** {1 Pretty Printing} *)
192
193let pp_proxy_type ppf = function
194 | HTTP -> Format.fprintf ppf "HTTP"
195 | SOCKS5 -> Format.fprintf ppf "SOCKS5"
196
197let pp_config ppf config =
198 Format.fprintf ppf "@[<v>Proxy Configuration:@,";
199 Format.fprintf ppf " Type: %a@," pp_proxy_type config.proxy_type;
200 Format.fprintf ppf " Host: %s@," config.host;
201 Format.fprintf ppf " Port: %d@," config.port;
202 Format.fprintf ppf " Auth: %s@,"
203 (if Option.is_some config.auth then "[CONFIGURED]" else "None");
204 Format.fprintf ppf " No-proxy: [%s]@]"
205 (String.concat ", " config.no_proxy)