1 import Base 2 import ./utf8.bend as U 3 import ./base64.bend as B 4 import ./config.bend as K 5 6 # Proxy 7 # ===== 8 # 9 # Whether and how a connection to the API server goes through a proxy, as 10 # client-go decides it (Go's http.ProxyFromEnvironment): HTTPS_PROXY names 11 # an http:// proxy, optionally with user:password; NO_PROXY lists what 12 # bypasses it. Through a proxy, the connection is a CONNECT tunnel, and TLS 13 # (with its verification) still runs end to end with the API server. 14 # 15 # Pure: net.bend reads the environment, dials, and feeds the proxy's answer 16 # to Tunnel; the effects never decide anything about proxies. 17 18 type Proxy is Data: 19 Proxy{host: String, port: U32, auth: String} 20 21 # auth is the Basic credential (base64 of "user:password"), "" for none. 22 type Route is Data: 23 Direct{} 24 Via{proxy: Proxy} 25 NoRoute{why: String} 26 27 # IPv4 28 # ---- 29 30 def Ip4.octet.if(+v: U32, ok: Bool) -> Maybe<&2, U32>: 31 match ok: 32 case True{}: 33 Some{v} 34 case False{}: 35 None{} 36 37 def Ip4.octet.go(m: Maybe<&2, U32>) -> Maybe<&2, U32>: 38 match m: 39 case Some{+v}: 40 Ip4.octet.if(v, U32.is_le(v, 255)) 41 case None{}: 42 None{} 43 44 def Ip4.octet(s: String) -> Maybe<&2, U32>: 45 Ip4.octet.go(U32.read(s)) 46 47 def Ip4.join(a: Maybe<&2, U32>, b: Maybe<&2, U32>, c: Maybe<&2, U32>, d: Maybe<&2, U32>) -> 48 Maybe<&2, U32>: 49 match a b c d: 50 case Some{w} Some{x} Some{y} Some{z}: 51 Some{U32.add(U32.mul(U32.add(U32.mul(U32.add(U32.mul(w, 256), x), 256), y), 256), z)} 52 case _ _ _ _: 53 None{} 54 55 def Ip4.parse.go(ps: List<&2, String>) -> Maybe<&2, U32>: 56 match ps: 57 case Con{a, Con{b, Con{c, Con{d, Nil{}}}}}: 58 Ip4.join(Ip4.octet(a), Ip4.octet(b), Ip4.octet(c), Ip4.octet(d)) 59 case _: 60 None{} 61 62 # "10.1.2.3" as a number, if it is a dotted IPv4 address. 63 def Ip4.parse(s: String) -> Maybe<&2, U32>: 64 Ip4.parse.go(String.split(s, '.')) 65 66 def Ip4.is_loopback(m: Maybe<&2, U32>) -> Bool: 67 match m: 68 case Some{v}: 69 U32.is_eq(U32.shrn(v, 24n), 127) 70 case None{}: 71 False{} 72 73 # The mask of a prefix length; None past 32. 74 def Ip4.mask.go(+bits: U32, ok: Bool) -> Maybe<&2, U32>: 75 match ok: 76 case True{}: 77 Some{U32.shln(4294967295, U32.to_nat(U32.sub(32, bits)))} 78 case False{}: 79 None{} 80 81 def Ip4.mask(+bits: U32) -> Maybe<&2, U32>: 82 Ip4.mask.go(bits, U32.is_le(bits, 32)) 83 84 def Ip4.in(h: Maybe<&2, U32>, n: Maybe<&2, U32>, m: Maybe<&2, U32>) -> Bool: 85 match h n m: 86 case Some{x} Some{y} Some{+k}: 87 U32.is_eq(U32.and(x, k), U32.and(y, k)) 88 case _ _ _: 89 False{} 90 91 def Ip4.mask.of(m: Maybe<&2, U32>) -> Maybe<&2, U32>: 92 match m: 93 case Some{+b}: 94 Ip4.mask(b) 95 case None{}: 96 None{} 97 98 def Ip4.cidr.go(h: String, parts: List<&2, String>) -> Bool: 99 match parts: 100 case Con{n, Con{b, Nil{}}}: 101 Ip4.in(Ip4.parse(h), Ip4.parse(n), Ip4.mask.of(U32.read(b))) 102 case _: 103 False{} 104 105 # Whether IPv4 host h lies in the "net/bits" block. 106 def Ip4.cidr(h: String, block: String) -> Bool: 107 Ip4.cidr.go(h, String.split(block, '/')) 108 109 # NO_PROXY 110 # -------- 111 # 112 # Entries are separated by commas or spaces. "*" bypasses every host; an 113 # IPv4 CIDR bypasses the addresses in it; "name" bypasses the name and its 114 # subdomains, ".name" (or "*.name") only its subdomains; ":port" on an entry 115 # narrows it to that port. Hosts compare in lower case. 116 117 # An entry, split from its port ("" for any). 118 type Ent is Data: 119 Ent{host: String, port: String} 120 121 def Ent.of.go(parts: List<&2, String>, +item: String) -> Ent: 122 match parts: 123 case Con{h, Con{p, Nil{}}}: 124 Ent{h, p} 125 case _: 126 Ent{item, ""} 127 128 def Ent.of(+item: String) -> Ent: 129 Ent.of.go(String.split(item, ':'), item) 130 131 def NoProxy.star(e: String) -> String: 132 match e: 133 case SCon{'*', SCon{'.', t}}: 134 SCon{'.', t} 135 case x: 136 x 137 138 def NoProxy.name.go(+e: String, +host: String, dot: Bool) -> Bool: 139 match dot: 140 case True{}: 141 String.ends_with(host, e) 142 case False{}: 143 Bool.or(String.eq(host, e), String.ends_with(host, "." ++ e)) 144 145 def NoProxy.name(+e: String, +host: String) -> Bool: 146 NoProxy.name.go(e, host, String.starts_with(e, ".")) 147 148 def NoProxy.port_ok(+want: String, +port: U32) -> Bool: 149 Bool.or(String.is_empty(want), String.eq(want, U32.show(port))) 150 151 def NoProxy.ent(e: Ent, +host: String, +port: U32) -> Bool: 152 match e: 153 case Ent{+h, +p}: 154 Bool.and(NoProxy.port_ok(p, port), 155 Bool.or(Ip4.cidr(host, h), NoProxy.name(NoProxy.star(h), host))) 156 157 def NoProxy.hit.go(+host: String, +port: U32, +item: String, star: Bool) -> Bool: 158 match star: 159 case True{}: 160 True{} 161 case False{}: 162 NoProxy.ent(Ent.of(item), host, port) 163 164 def NoProxy.hit(+host: String, +port: U32, +item: String) -> Bool: 165 NoProxy.hit.go(host, port, item, String.eq(item, "*")) 166 167 def NoProxy.any(xs: List<&2, String>, +host: String, +port: U32) -> Bool: 168 match xs: 169 case Nil{}: 170 False{} 171 case Con{x, t}: 172 Bool.or(NoProxy.hit(host, port, x), NoProxy.any(t, host, port)) 173 174 def NoProxy.commas(s: String) -> String: 175 match s: 176 case SNil{}: 177 SNil{} 178 case SCon{' ', t}: 179 SCon{',', NoProxy.commas(t)} 180 case SCon{c, t}: 181 SCon{c, NoProxy.commas(t)} 182 183 # Whether NO_PROXY (no) sends host:port around the proxy. 184 def NoProxy.bypass(no: String, +host: String, +port: U32) -> Bool: 185 NoProxy.any(String.split(String.to_lower(NoProxy.commas(no)), ','), host, port) 186 187 # Proxy URLs 188 # ---------- 189 190 def Pct.hex.go(+c: U32, d: Bool, up: Bool, low: Bool) -> U32: 191 match d up low: 192 case True{} _ _: 193 U32.sub(c, 48) 194 case _ True{} _: 195 U32.sub(c, 55) 196 case _ _ True{}: 197 U32.sub(c, 87) 198 case _ _ _: 199 16 200 201 # A hex digit's value, 16 for any other character. 202 def Pct.hex(+c: U32) -> U32: 203 Pct.hex.go(c, Bool.and(U32.is_ge(c, 48), U32.is_le(c, 57)), 204 Bool.and(U32.is_ge(c, 65), U32.is_le(c, 70)), Bool.and(U32.is_ge(c, 97), U32.is_le(c, 102))) 205 206 def Pct.esc.if(+a: Char, +b: Char, +hi: U32, +lo: U32, rest: List<&2, U32>, ok: Bool) -> 207 List<&2, U32>: 208 match ok: 209 case True{}: 210 Con{U32.add(U32.mul(hi, 16), lo), rest} 211 case False{}: 212 Con{37, U.Utf8.put(Char.to_u32(a), U.Utf8.put(Char.to_u32(b), rest))} 213 214 def Pct.esc(+a: Char, +b: Char, rest: List<&2, U32>) -> List<&2, U32>: 215 +hi = Pct.hex(Char.to_u32(a)) 216 +lo = Pct.hex(Char.to_u32(b)) 217 Pct.esc.if(a, b, hi, lo, rest, Bool.and(U32.is_lt(hi, 16), U32.is_lt(lo, 16))) 218 219 # The bytes of a percent-encoded URL part ("%40" is '@'); a '%' that starts 220 # no escape stays as it is. 221 def Pct.decode(s: String) -> List<&2, U32>: 222 match s: 223 case SNil{}: 224 Nil{} 225 case SCon{'%', SCon{a, SCon{b, t}}}: 226 Pct.esc(a, b, Pct.decode(t)) 227 case SCon{c, t}: 228 U.Utf8.put(Char.to_u32(c), Pct.decode(t)) 229 230 def Proxy.parse.fin(+h: String, +p: U32, auth: String, empty: Bool) -> Route: 231 match empty: 232 case True{}: 233 NoRoute{"HTTPS_PROXY names no host"} 234 case False{}: 235 Via{Proxy{h, p, auth}} 236 237 def Proxy.parse.hp(hp: String & U32, auth: String) -> Route: 238 match hp: 239 case (+h, +p): 240 Proxy.parse.fin(h, p, auth, String.is_empty(h)) 241 242 # The authority's parts split at '@', last first: the host, then the 243 # user info (which may itself hold an '@'). 244 def Proxy.parse.at(rs: List<&2, String>) -> Route: 245 match rs: 246 case Con{hp, Nil{}}: 247 Proxy.parse.hp(K.Url.host_port(hp, 80), "") 248 case Con{hp, us}: 249 Proxy.parse.hp(K.Url.host_port(hp, 80), 250 B.B64.encode(Pct.decode(String.join(List.reverse(&2, String, us), "@")))) 251 case Nil{}: 252 NoRoute{"HTTPS_PROXY names no host"} 253 254 def Proxy.parse.auth(ap: String & String) -> Route: 255 match ap: 256 case (a, p): 257 Proxy.parse.at(List.reverse(&2, String, String.split(a, '@'))) 258 259 def Proxy.parse.go(+u: String, http: Bool, other: Bool) -> Route: 260 match http other: 261 case True{} _: 262 Proxy.parse.auth(K.Url.split_path(String.drop(u, 7n), "")) 263 case False{} True{}: 264 NoRoute{"HTTPS_PROXY: only http:// proxies are supported"} 265 case False{} False{}: 266 Proxy.parse.auth(K.Url.split_path(u, "")) 267 268 # "http://user:pass@host:port", or "host:port" (http:// is assumed, as Go 269 # does). Other schemes fail rather than silently connecting directly. 270 def Proxy.parse(+u: String) -> Route: 271 Proxy.parse.go(u, String.starts_with(String.to_lower(u), "http://"), String.contains(u, "://")) 272 273 # Routes 274 # ------ 275 276 def Proxy.route.url(+url: String, empty: Bool) -> Route: 277 match empty: 278 case True{}: 279 Direct{} 280 case False{}: 281 Proxy.parse(String.trim(url)) 282 283 def Proxy.route.no(+url: String, by: Bool) -> Route: 284 match by: 285 case True{}: 286 Direct{} 287 case False{}: 288 Proxy.route.url(url, String.is_empty(String.trim(url))) 289 290 def Proxy.route.local(+url: String, no: String, +host: String, +port: U32, local: Bool) -> 291 Route: 292 match local: 293 case True{}: 294 Direct{} 295 case False{}: 296 Proxy.route.no(url, NoProxy.bypass(no, host, port)) 297 298 def Proxy.is_local(+host: String) -> Bool: 299 Bool.or(Bool.or(String.eq(host, "localhost"), String.eq(host, "::1")), 300 Ip4.is_loopback(Ip4.parse(host))) 301 302 # How to reach host:port, given HTTPS_PROXY (url) and NO_PROXY (no): 303 # localhost and loopback addresses never use a proxy (as in Go), nor do 304 # hosts NO_PROXY lists, nor anything when no proxy is set. 305 def Proxy.route(+url: String, no: String, host: String, +port: U32) -> Route: 306 +h = String.to_lower(host) 307 Proxy.route.local(url, no, h, port, Proxy.is_local(h)) 308 309 # The CONNECT 310 # ----------- 311 312 def Proxy.target.go(+h: String, +p: U32, v6: Bool) -> String: 313 match v6: 314 case True{}: 315 "[" ++ h ++ "]:" ++ U32.show(p) 316 case False{}: 317 h ++ ":" ++ U32.show(p) 318 319 def Proxy.target(+h: String, +p: U32) -> String: 320 Proxy.target.go(h, p, String.contains(h, ":")) 321 322 def Proxy.auth_line.go(+auth: String, none: Bool) -> String: 323 match none: 324 case True{}: 325 "" 326 case False{}: 327 "Proxy-Authorization: Basic " ++ auth ++ "\r\n" 328 329 # The request that opens a tunnel to host:port. 330 def Proxy.connect(+host: String, +port: U32, +auth: String) -> String: 331 +t = Proxy.target(host, port) 332 "CONNECT " ++ t ++ " HTTP/1.1\r\nHost: " ++ t ++ "\r\n" 333 ++ Proxy.auth_line.go(auth, String.is_empty(auth)) ++ "\r\n" 334 335 # The proxy's answer 336 # ------------------ 337 # 338 # Bytes of the answer's head accumulate (last first) until its blank line; 339 # a 200 opens the tunnel. Any byte after the head would belong to the TLS 340 # stream before we sent a ClientHello, so it fails the tunnel, as does a 341 # head past 16KiB or a connection that ends first. 342 343 type Tunnel is Data: 344 TMore{head: List<&2, U32>} 345 TOpen{} 346 TFail{why: String} 347 348 def Tunnel.init() -> Tunnel: 349 TMore{Nil{}} 350 351 def Tunnel.is_end(+h: List<&2, U32>) -> Bool: 352 match h: 353 case Con{+a, Con{+b, Con{+c, Con{+d, t}}}}: 354 Bool.and(Bool.and(U32.is_eq(a, 10), U32.is_eq(b, 13)), Bool.and(U32.is_eq(c, 10), U32.is_eq(d, 13))) 355 case _: 356 False{} 357 358 def Tunnel.line.if(+b: U32, rest: List<&2, U32>, cr: Bool) -> List<&2, U32>: 359 match cr: 360 case True{}: 361 Nil{} 362 case False{}: 363 Con{b, rest} 364 365 # The bytes before the first CR. 366 def Tunnel.line(bs: List<&2, U32>) -> List<&2, U32>: 367 match bs: 368 case Nil{}: 369 Nil{} 370 case Con{+b, t}: 371 Tunnel.line.if(b, Tunnel.line(t), U32.is_eq(b, 13)) 372 373 def Tunnel.verdict.go(+line: String, ok: Bool) -> Tunnel: 374 match ok: 375 case True{}: 376 TOpen{} 377 case False{}: 378 TFail{"proxy refused CONNECT: " ++ line} 379 380 def Tunnel.verdict(+line: String) -> Tunnel: 381 Tunnel.verdict.go(line, Bool.and(String.starts_with(line, "HTTP/1."), 382 String.starts_with(String.drop(line, 9n), "200"))) 383 384 def Tunnel.done(h: List<&2, U32>, rest: List<&2, U32>) -> Tunnel: 385 match rest: 386 case Nil{}: 387 Tunnel.verdict(U.Utf8.decode_all(Tunnel.line(List.reverse(&2, U32, h)))) 388 case Con{b, t}: 389 TFail{"proxy sent bytes before the TLS handshake"} 390 391 def Tunnel.push(bs: List<&2, U32>, +h: List<&2, U32>, end: Bool) -> Tunnel: 392 match bs end: 393 case rest True{}: 394 Tunnel.done(h, rest) 395 case Nil{} False{}: 396 TMore{h} 397 case Con{+b, t} False{}: 398 Tunnel.push(t, Con{b, h}, Tunnel.is_end(Con{b, h})) 399 400 def Tunnel.cap.go(+h: List<&2, U32>, long: Bool) -> Tunnel: 401 match long: 402 case True{}: 403 TFail{"proxy answer too long"} 404 case False{}: 405 TMore{h} 406 407 def Tunnel.cap(t: Tunnel) -> Tunnel: 408 match t: 409 case TMore{+h}: 410 Tunnel.cap.go(h, Nat.is_gt(List.length(&2, U32, h), 16384n)) 411 case x: 412 x 413 414 def Tunnel.more(+h: List<&2, U32>, bs: List<&2, U32>, closed: Bool) -> Tunnel: 415 match closed: 416 case True{}: 417 TFail{"proxy closed the connection before answering CONNECT"} 418 case False{}: 419 Tunnel.cap(Tunnel.push(bs, h, False{})) 420 421 # The tunnel after the next bytes of the answer; no bytes is the end. 422 def Tunnel.feed(t: Tunnel, +bs: List<&2, U32>) -> Tunnel: 423 match t: 424 case TMore{+h}: 425 Tunnel.more(h, bs, List.is_empty(&2, U32, bs)) 426 case x: 427 x