A batteries included HTTP/1.1 client in OCaml
at main 208 lines 6.9 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: 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)