A batteries included HTTP/1.1 client in OCaml
at claude-test 205 lines 7.2 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** HTTP 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)