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