forked from
anil.recoil.org/ocaml-requests
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 Configuration Utilities} *)
40
41let should_bypass config url =
42 let uri = Uri.of_string url in
43 let target_host = Uri.host uri |> Option.value ~default:"" in
44 let target_host_lower = String.lowercase_ascii target_host in
45
46 let matches_pattern pattern =
47 let pattern_lower = String.lowercase_ascii (String.trim pattern) in
48 if String.length pattern_lower = 0 then
49 false
50 else if pattern_lower.[0] = '*' then
51 (* Wildcard pattern: *.example.com matches foo.example.com *)
52 let suffix = String.sub pattern_lower 1 (String.length pattern_lower - 1) in
53 String.length target_host_lower >= String.length suffix &&
54 String.sub target_host_lower
55 (String.length target_host_lower - String.length suffix)
56 (String.length suffix) = suffix
57 else if pattern_lower.[0] = '.' then
58 (* .example.com matches example.com and foo.example.com *)
59 target_host_lower = String.sub pattern_lower 1 (String.length pattern_lower - 1) ||
60 (String.length target_host_lower > String.length pattern_lower &&
61 String.sub target_host_lower
62 (String.length target_host_lower - String.length pattern_lower)
63 (String.length pattern_lower) = pattern_lower)
64 else
65 (* Exact match *)
66 target_host_lower = pattern_lower
67 in
68
69 let bypassed = List.exists matches_pattern config.no_proxy in
70 if bypassed then
71 Log.debug (fun m -> m "URL %s bypasses proxy (matches no_proxy pattern)"
72 (Error.sanitize_url url));
73 bypassed
74
75let host_port config = (config.host, config.port)
76
77(** {1 Environment Variable Support} *)
78
79let get_env key =
80 try Some (Sys.getenv key) with Not_found -> None
81
82let get_env_insensitive key =
83 match get_env key with
84 | Some v -> Some v
85 | None -> get_env (String.lowercase_ascii key)
86
87let parse_no_proxy () =
88 let no_proxy_str =
89 match get_env "NO_PROXY" with
90 | Some v -> v
91 | None ->
92 match get_env "no_proxy" with
93 | Some v -> v
94 | None -> ""
95 in
96 no_proxy_str
97 |> String.split_on_char ','
98 |> List.map String.trim
99 |> List.filter (fun s -> String.length s > 0)
100
101let parse_proxy_url url =
102 let uri = Uri.of_string url in
103 let host = Uri.host uri |> Option.value ~default:"localhost" in
104 let port = Uri.port uri |> Option.value ~default:8080 in
105 let auth = match Uri.userinfo uri with
106 | Some info ->
107 (match String.index_opt info ':' with
108 | Some idx ->
109 let username = String.sub info 0 idx in
110 let password = String.sub info (idx + 1) (String.length info - idx - 1) in
111 Some (Auth.basic ~username ~password)
112 | None ->
113 (* Username only, no password *)
114 Some (Auth.basic ~username:info ~password:""))
115 | None -> None
116 in
117 (host, port, auth)
118
119let from_env () =
120 let no_proxy = parse_no_proxy () in
121 let proxy_url =
122 match get_env_insensitive "HTTP_PROXY" with
123 | Some url -> Some url
124 | None ->
125 match get_env_insensitive "HTTPS_PROXY" with
126 | Some url -> Some url
127 | None -> get_env_insensitive "ALL_PROXY"
128 in
129 match proxy_url with
130 | Some url ->
131 let (host, port, auth) = parse_proxy_url url in
132 Log.info (fun m -> m "Proxy configured from environment: %s:%d" host port);
133 Some { host; port; proxy_type = HTTP; auth; no_proxy }
134 | None ->
135 Log.debug (fun m -> m "No proxy configured in environment");
136 None
137
138let from_env_for_url url =
139 let uri = Uri.of_string url in
140 let is_https = Uri.scheme uri = Some "https" in
141 let no_proxy = parse_no_proxy () in
142
143 (* Check if URL should bypass proxy *)
144 let target_host = Uri.host uri |> Option.value ~default:"" in
145 let should_bypass_url =
146 let target_host_lower = String.lowercase_ascii target_host in
147 List.exists (fun pattern ->
148 let pattern_lower = String.lowercase_ascii (String.trim pattern) in
149 if String.length pattern_lower = 0 then false
150 else if pattern_lower.[0] = '*' then
151 let suffix = String.sub pattern_lower 1 (String.length pattern_lower - 1) in
152 String.length target_host_lower >= String.length suffix &&
153 String.sub target_host_lower
154 (String.length target_host_lower - String.length suffix)
155 (String.length suffix) = suffix
156 else if pattern_lower.[0] = '.' then
157 target_host_lower = String.sub pattern_lower 1 (String.length pattern_lower - 1) ||
158 (String.length target_host_lower > String.length pattern_lower &&
159 String.sub target_host_lower
160 (String.length target_host_lower - String.length pattern_lower)
161 (String.length pattern_lower) = pattern_lower)
162 else
163 target_host_lower = pattern_lower
164 ) 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)