src/proxy.bend

bend-kube-runtime@0.2.0.0 · 427 lines · 61 definitions

1import Base
2import bend-kube-runtime@0.2.0.0/src/utf8.bend as U
3import bend-kube-runtime@0.2.0.0/src/base64.bend as B
4import bend-kube-runtime@0.2.0.0/src/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
18type Proxy is Data:
19  Proxy{host: String, port: U32, auth: String}
20
21# auth is the Basic credential (base64 of "user:password"), "" for none.
22type Route is Data:
23  Direct{}
24  Via{proxy: Proxy}
25  NoRoute{why: String}
26
27# IPv4
28# ----
29
30def 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
37def 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
44def Ip4.octet(s: String) -> Maybe<&2, U32>:
45  Ip4.octet.go(U32.read(s))
46
47def 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
55def 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.
63def Ip4.parse(s: String) -> Maybe<&2, U32>:
64  Ip4.parse.go(String.split(s, '.'))
65
66def 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.
74def 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
81def Ip4.mask(+bits: U32) -> Maybe<&2, U32>:
82  Ip4.mask.go(bits, U32.is_le(bits, 32))
83
84def 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
91def 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
98def 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.
106def 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).
118type Ent is Data:
119  Ent{host: String, port: String}
120
121def 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
128def Ent.of(+item: String) -> Ent:
129  Ent.of.go(String.split(item, ':'), item)
130
131def NoProxy.star(e: String) -> String:
132  match e:
133    case SCon{'*', SCon{'.', t}}:
134      SCon{'.', t}
135    case x:
136      x
137
138def 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
145def NoProxy.name(+e: String, +host: String) -> Bool:
146  NoProxy.name.go(e, host, String.starts_with(e, "."))
147
148def NoProxy.port_ok(+want: String, +port: U32) -> Bool:
149  Bool.or(String.is_empty(want), String.eq(want, U32.show(port)))
150
151def 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
157def 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
164def NoProxy.hit(+host: String, +port: U32, +item: String) -> Bool:
165  NoProxy.hit.go(host, port, item, String.eq(item, "*"))
166
167def 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
174def 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.
184def 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
190def 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.
202def 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
206def 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
214def 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.
221def 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
230def 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
237def 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 '@').
244def 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
254def 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
259def 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.
270def 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
276def 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
283def 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
290def 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
298def 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.
305def 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
312def 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
319def Proxy.target(+h: String, +p: U32) -> String:
320  Proxy.target.go(h, p, String.contains(h, ":"))
321
322def 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.
330def 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
343type Tunnel is Data:
344  TMore{head: List<&2, U32>}
345  TOpen{}
346  TFail{why: String}
347
348def Tunnel.init() -> Tunnel:
349  TMore{Nil{}}
350
351def 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
358def 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.
366def 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
373def 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
380def 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
384def 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
391def 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
400def 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
407def 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
414def 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.
422def 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