├── .gitattributes ├── .github └── workflows │ ├── h2spec.yml │ └── main.yml ├── .gitignore ├── .ocamlformat ├── CHANGES.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── bench ├── dune ├── hello_world_eio.ml ├── hello_world_piaf.ml ├── http2.js ├── package-lock.json ├── package.json ├── run.js └── yarn.lock ├── dune-project ├── examples ├── dune └── hello_world.ml ├── flake.lock ├── flake.nix ├── nomad.opam ├── nomad ├── adapter.ml ├── config.ml ├── connection_handler.ml ├── dune ├── events.ml ├── frame.ml ├── handler.ml ├── http1.ml ├── http2.ml ├── negotiator.ml ├── nomad.ml ├── nomad.mli ├── protocol.ml ├── request.ml └── ws.ml └── test ├── autobahn ├── fuzzingclient.json ├── fuzzingserver.json ├── nomad.json ├── run └── server.ml ├── bandit ├── .ackrc ├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github │ ├── dependabot.yml │ └── workflows │ │ ├── autobahn.yml │ │ ├── benchmark.yml │ │ ├── elixir.yml │ │ ├── h2spec.yml │ │ └── manual_benchmark.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib │ └── bandit_headers.ex ├── mix.exs ├── mix.lock └── test │ ├── bandit │ └── http1 │ │ └── request_test.exs │ ├── support │ ├── ca.pem │ ├── cert.pem │ ├── key.pem │ ├── noop_sock.ex │ ├── req_helpers.ex │ ├── sendfile │ ├── sendfile_large │ ├── server_helpers.ex │ ├── simple_h2_client.ex │ ├── simple_http1_client.ex │ ├── simple_websocket_client.ex │ ├── telemetry_collector.ex │ ├── test_helpers.ex │ └── transport.ex │ └── test_helper.exs ├── dune ├── h2spec ├── dune ├── h2spec.xml ├── run ├── server.ml ├── tls.crt └── tls.key └── http_test.ml /.gitattributes: -------------------------------------------------------------------------------- 1 | test/bandit/**/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/workflows/h2spec.yml: -------------------------------------------------------------------------------- 1 | name: Run h2spec 2 | 3 | on: 4 | pull_request: 5 | push: 6 | schedule: 7 | # Prime the caches every Monday 8 | - cron: 0 1 * * MON 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | h2spec: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | #- macos-latest 20 | ocaml-compiler: 21 | - "5.1" 22 | allow-prerelease-opam: 23 | - true 24 | opam-repositories: 25 | - |- 26 | default: https://github.com/ocaml/opam-repository.git 27 | # include: 28 | # - os: windows-latest 29 | # ocaml-compiler: ocaml-variants.5.1.0+options,ocaml-option-mingw 30 | # allow-prerelease-opam: false 31 | # opam-repositories: |- 32 | # windows-5.0: https://github.com/dra27/opam-repository.git#windows-5.0 33 | # sunset: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset 34 | # default: https://github.com/ocaml/opam-repository.git 35 | 36 | runs-on: ${{ matrix.os }} 37 | 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | 42 | - name: Set-up OCaml 43 | uses: ocaml/setup-ocaml@v2 44 | with: 45 | ocaml-compiler: ${{ matrix.ocaml-compiler }} 46 | allow-prerelease-opam: ${{ matrix.allow-prerelease-opam }} 47 | opam-repositories: ${{ matrix.opam-repositories }} 48 | 49 | - run: opam install . --deps-only --with-test 50 | 51 | - run: opam exec -- dune build --release 52 | 53 | - name: Run h2spec test 54 | run: opam exec -- dune exec ./test/h2spec.exe 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | schedule: 7 | # Prime the caches every Monday 8 | - cron: 0 1 * * MON 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - macos-latest 19 | - ubuntu-latest 20 | ocaml-compiler: 21 | - "5.1" 22 | allow-prerelease-opam: 23 | - true 24 | opam-repositories: 25 | - |- 26 | default: https://github.com/ocaml/opam-repository.git 27 | # include: 28 | # - os: windows-latest 29 | # ocaml-compiler: ocaml-variants.5.1.0+options,ocaml-option-mingw 30 | # allow-prerelease-opam: false 31 | # opam-repositories: |- 32 | # windows-5.0: https://github.com/dra27/opam-repository.git#windows-5.0 33 | # sunset: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset 34 | # default: https://github.com/ocaml/opam-repository.git 35 | 36 | runs-on: ${{ matrix.os }} 37 | 38 | steps: 39 | - name: Checkout tree 40 | uses: actions/checkout@v4 41 | 42 | - name: Set-up OCaml 43 | uses: ocaml/setup-ocaml@v2 44 | with: 45 | ocaml-compiler: ${{ matrix.ocaml-compiler }} 46 | allow-prerelease-opam: ${{ matrix.allow-prerelease-opam }} 47 | opam-repositories: ${{ matrix.opam-repositories }} 48 | 49 | - run: opam install . --deps-only --with-test 50 | 51 | - run: opam exec -- dune build 52 | 53 | - run: opam exec -- dune test 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | # nix ignores 3 | .direnv 4 | result 5 | .envrc 6 | -------------------------------------------------------------------------------- /.ocamlformat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suri-framework/nomad/4d9289e0ee14305f8474b7067330fcb5b696a17d/.ocamlformat -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 0.0.1 2 | 3 | Initial release, including: 4 | 5 | * Interchangeable protocols 6 | 7 | * Incomplete HTTP/1 and WebSocket protocol support 8 | 9 | * A configurable HTTP parser with limits for line requests, header count, and 10 | header length 11 | 12 | * Pluggable transports to transparently support clear sockets and TLS 13 | 14 | * A Trail based handler interface 15 | 16 | * A Hello World example 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project has adopted the [OCaml Code of 4 | Conduct](https://github.com/ocaml/code-of-conduct/blob/main/CODE_OF_CONDUCT.md). 5 | 6 | # Enforcement 7 | 8 | This project follows the OCaml Code of Conduct 9 | [enforcement policy](https://github.com/ocaml/code-of-conduct/blob/main/CODE_OF_CONDUCT.md#enforcement). 10 | 11 | To report any violations, please contact: 12 | 13 | * Leandro Ostera 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, Leandro Ostera 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nomad 2 | 3 | Nomad is an HTTP server for [Trail][trail] apps inspired by [Bandit][bandit]. 4 | 5 | Nomad is written entirely in OCaml and is built atop [Atacama][atacama]. It 6 | aims to be an Application-layer for Trail, implementing: HTTP/1.x, HTTP/2, and 7 | WebSockets. It is written with a big focus on clarity. 8 | 9 | [atacama]: https://github.com/suri-framework/atacama 10 | [trail]: https://github.com/suri-framework/trail 11 | [bandit]: https://github.com/mtrudel/bandit 12 | 13 | ## Correctness 14 | 15 | Nomad aims to be **correct** and so we're testing against the [Bandit HTTP/1.1 16 | test-bed][bandit-tests], [h2spec][h2spec], and [Autobahn][autobahn] 17 | 18 | [bandit-tests]: ./test/bandit/test/bandit/http1/request_test.exs 19 | [h2spec]: https://github.com/summerwind/h2spec 20 | [autobahn]: https://github.com/crossbario/autobahn-testsuite 21 | 22 | ### HTTP/1.1 23 | 24 | - [x] invalid requests 25 | - [x] returns a 400 if the request cannot be parsed 26 | - [x] returns a 400 if the request has an invalid http version 27 | - [x] keepalive requests 28 | - [x] closes connection after max_requests is reached 29 | - [x] idle keepalive connections are closed after read_timeout 30 | - [x] unread content length bodies are read before starting a new request 31 | - [x] unread chunked bodies are read before starting a new request 32 | - [x] origin-form request target (RFC9112§3.2.1) 33 | - [x] derives scheme from underlying transport 34 | - [x] derives host from host header 35 | - [x] returns 400 if no host header set in HTTP/1.1 36 | - [x] sets a blank host if no host header set in HTTP/1.0 37 | - [x] derives port from host header 38 | - [x] derives host from host header with ipv6 host 39 | - [x] derives host and port from host header with ipv6 host 40 | - [ ] returns 400 if port cannot be parsed from host header 41 | - [x] derives port from schema default if no port specified in host header 42 | - [x] derives port from schema default if no host header set in HTTP/1.0 43 | - [x] sets path and query string properly when no query string is present 44 | - [x] sets path and query string properly when query string is present 45 | - [x] ignores fragment when no query string is present 46 | - [x] ignores fragment when query string is present 47 | - [x] handles query strings with question mark characters in them 48 | - [x] returns 400 if a non-absolute path is send 49 | - [x] returns 400 if path has no leading slash 50 | - [x] absolute-form request target (RFC9112§3.2.2) 51 | - [x] uses request-line scheme even if it does not match the transport 52 | - [x] derives host from the URI, even if it differs from host header 53 | - [x] derives ipv6 host from the URI, even if it differs from host header 54 | - [x] does not require a host header set in HTTP/1.1 (RFC9112§3.2.2) 55 | - [x] derives port from the URI, even if it differs from host header 56 | - [x] derives port from schema default if no port specified in the URI 57 | - [x] sets path and query string properly when no query string is present 58 | - [x] sets path and query string properly when query string is present 59 | - [x] ignores fragment when no query string is present 60 | - [x] ignores fragment when query string is present 61 | - [x] handles query strings with question mark characters in them 62 | - [x] authority-form request target (RFC9112§3.2.3) 63 | - [x] returns 400 for authority-form / CONNECT requests 64 | - [ ] asterisk-form request target (RFC9112§3.2.4) 65 | - [ ] parse global OPTIONS path correctly 66 | - [ ] request line limits 67 | - [ ] returns 414 for request lines that are too long 68 | - [ ] request headers 69 | - [ ] reads headers properly 70 | - [ ] returns 431 for header lines that are too long 71 | - [ ] returns 431 for too many header lines 72 | - [ ] content-length request bodies 73 | - [ ] reads a zero length body properly 74 | - [ ] reads a content-length encoded body properly 75 | - [ ] reads a content-length with multiple content-lengths encoded body properly 76 | - [ ] rejects a request with non-matching multiple content lengths 77 | - [ ] rejects a request with negative content-length 78 | - [ ] rejects a request with non-integer content length 79 | - [ ] handles the case where we ask for less than is already in the buffer 80 | - [ ] handles the case where we ask for more than is already in the buffer 81 | - [ ] handles the case where we read from the network in smaller chunks than we return 82 | - [ ] handles the case where the declared content length is longer than what is sent 83 | - [ ] handles the case where the declared content length is less than what is sent 84 | - [ ] reading request body multiple times works as expected 85 | - [ ] chunked request bodies 86 | - [ ] reads a chunked body properly 87 | - [ ] upgrade handling 88 | - [ ] raises an ArgumentError on unsupported upgrades 89 | - [ ] returns a 400 and errors loudly in cases where an upgrade is indicated but the connection is not a GET 90 | - [ ] returns a 400 and errors loudly in cases where an upgrade is indicated but upgrade header is incorrect 91 | - [ ] returns a 400 and errors loudly in cases where an upgrade is indicated but connection header is incorrect 92 | - [ ] returns a 400 and errors loudly in cases where an upgrade is indicated but key header is incorrect 93 | - [ ] returns a 400 and errors loudly in cases where an upgrade is indicated but version header is incorrect 94 | - [ ] returns a 400 and errors loudly if websocket support is not enabled 95 | - [ ] response headers 96 | - [ ] writes out a response with a valid date header 97 | - [ ] returns user-defined date header instead of internal version 98 | - [ ] response body 99 | - [x] writes out a response with deflate encoding if so negotiated 100 | - [x] writes out a response with gzip encoding if so negotiated 101 | - [ ] writes out a response with x-gzip encoding if so negotiated 102 | - [x] uses the first matching encoding in accept-encoding 103 | - [x] falls back to no encoding if no encodings provided 104 | - [x] does no encoding if content-encoding header already present in response 105 | - [x] does no encoding if a strong etag is present in the response 106 | - [x] does content encoding if a weak etag is present in the response 107 | - [x] does no encoding if cache-control: no-transform is present in the response 108 | - [x] falls back to no encoding if no encodings match 109 | - [ ] falls back to no encoding if compression is disabled 110 | - [x] sends expected content-length but no body for HEAD requests 111 | - [x] replaces any incorrect provided content-length headers 112 | - [x] writes out a response with no content-length header or body for 204 responses 113 | - [x] writes out a response with no content-length header or body for 304 responses 114 | - [x] writes out a response with zero content-length for 200 responses 115 | - [x] writes out a response with zero content-length for 301 responses 116 | - [x] writes out a response with zero content-length for 401 responses 117 | - [x] writes out a chunked response 118 | - [x] does not write out a body for a chunked response to a HEAD request 119 | - [x] returns socket errors on chunk calls 120 | - [x] writes out a sent file for the entire file with content length 121 | - [x] writes out headers but not body for files requested via HEAD request 122 | - [x] does not write out a content-length header or body for files on a 204 123 | - [x] does not write out a content-length header or body for files on a 304 124 | - [x] writes out a sent file for parts of a file with content length 125 | - [x] sending informational responses 126 | - [x] does not send informational responses to HTTP/1.0 clients 127 | - [x] reading HTTP version 128 | - [ ] reading peer data 129 | 130 | ### HTTP/2 131 | 132 | ### WebSockets 133 | -------------------------------------------------------------------------------- /bench/dune: -------------------------------------------------------------------------------- 1 | ;(executables 2 | ; (names hello_world_eio) 3 | ; (libraries eio eio_main cohttp cohttp-eio)) 4 | -------------------------------------------------------------------------------- /bench/hello_world_eio.ml: -------------------------------------------------------------------------------- 1 | let text = "hello world" 2 | 3 | let handler _socket request _body = 4 | match Http.Request.resource request with 5 | | "/" -> (Http.Response.make (), Cohttp_eio.Body.of_string text) 6 | | _ -> (Http.Response.make ~status:`Not_found (), Cohttp_eio.Body.of_string "") 7 | 8 | let () = 9 | let port = ref 8083 in 10 | Arg.parse 11 | [ ("-p", Arg.Set_int port, " Listening port number(8080 by default)") ] 12 | ignore "An HTTP/1.1 server"; 13 | Eio_main.run @@ fun env -> 14 | Eio.Switch.run @@ fun sw -> 15 | let socket = 16 | Eio.Net.listen env#net ~sw ~backlog:128 ~reuse_addr:true 17 | (`Tcp (Eio.Net.Ipaddr.V4.loopback, !port)) 18 | and server = Cohttp_eio.Server.make ~callback:handler () in 19 | Cohttp_eio.Server.run socket server ~on_error:raise 20 | -------------------------------------------------------------------------------- /bench/hello_world_piaf.ml: -------------------------------------------------------------------------------- 1 | open Piaf 2 | open Eio.Std 3 | 4 | let connection_handler (params : Request_info.t Server.ctx) = 5 | match params.request with 6 | | { Request.meth = `GET; _ } -> Response.of_string ~body:"hello world" `OK 7 | | _ -> 8 | let headers = Headers.of_list [ ("connection", "close") ] in 9 | Response.of_string ~headers `Method_not_allowed ~body:"" 10 | 11 | let run ~sw ~host ~port env handler = 12 | let config = 13 | Server.Config.create ~buffer_size:0x1000 ~domains:1 (`Tcp (host, port)) 14 | in 15 | let server = Server.create ~config handler in 16 | let command = Server.Command.start ~sw env server in 17 | command 18 | 19 | let start ~sw env = 20 | let host = Eio.Net.Ipaddr.V4.loopback in 21 | run ~sw ~host ~port:8080 env connection_handler 22 | 23 | let setup_log ?style_renderer level = 24 | Logs_threaded.enable (); 25 | Fmt_tty.setup_std_outputs ?style_renderer (); 26 | Logs.set_level ~all:true level; 27 | Logs.set_reporter (Logs_fmt.reporter ()) 28 | 29 | let () = 30 | setup_log (Some Info); 31 | Eio_main.run (fun env -> 32 | Switch.run (fun sw -> 33 | let _command = start ~sw env in 34 | ())) 35 | -------------------------------------------------------------------------------- /bench/http2.js: -------------------------------------------------------------------------------- 1 | const http2 = require('http2') 2 | 3 | const session = http2.connect('http://0.0.0.0:2112') 4 | 5 | session.on('error', (err) => console.error(err)) 6 | 7 | setTimeout(() => { 8 | 9 | const req = session.request({ ':path': '/' }) 10 | // since we don't have any more data to send as 11 | // part of the request, we can end it 12 | req.end() 13 | 14 | // This callback is fired once we receive a response 15 | // from the server 16 | req.on('response', (headers) => { 17 | // we can log each response header here 18 | for (const name in headers) { 19 | console.log(`${name}: ${headers[name]}`) 20 | } 21 | }) 22 | 23 | // To fetch the response body, we set the encoding 24 | // we want and initialize an empty data string 25 | req.setEncoding('utf8') 26 | let data = '' 27 | 28 | // append response data to the data string every time 29 | // we receive new data chunks in the response 30 | req.on('data', (chunk) => { data += chunk }) 31 | 32 | // Once the response is finished, log the entire data 33 | // that we received 34 | req.on('end', () => { 35 | console.log(`\n${data}`) 36 | // In this case, we don't want to make any more 37 | // requests, so we can close the session 38 | session.close() 39 | }) 40 | 41 | }, 500); 42 | -------------------------------------------------------------------------------- /bench/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bench", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "ws": "^8.16.0" 9 | } 10 | }, 11 | "node_modules/ws": { 12 | "version": "8.16.0", 13 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", 14 | "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", 15 | "engines": { 16 | "node": ">=10.0.0" 17 | }, 18 | "peerDependencies": { 19 | "bufferutil": "^4.0.1", 20 | "utf-8-validate": ">=5.0.2" 21 | }, 22 | "peerDependenciesMeta": { 23 | "bufferutil": { 24 | "optional": true 25 | }, 26 | "utf-8-validate": { 27 | "optional": true 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "ws": "^8.16.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /bench/run.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws") 2 | 3 | let ws = new WebSocket("wss://bazaar.fly.dev/ws") 4 | 5 | ws.on("open", () => { 6 | console.log("opened"); 7 | setInterval(() => { 8 | ws.send("hello world 🚀") 9 | }, 500); 10 | }) 11 | 12 | ws.on("close", () => { 13 | console.log("closed") 14 | }) 15 | 16 | ws.on("error", (err) => { 17 | console.error("error", err); 18 | }) 19 | 20 | ws.on("message", (msg) => { 21 | console.log("recv: ", msg.toString()); 22 | }) 23 | -------------------------------------------------------------------------------- /bench/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ws@^8.16.0: 6 | version "8.16.0" 7 | resolved "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz" 8 | integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== 9 | -------------------------------------------------------------------------------- /dune-project: -------------------------------------------------------------------------------- 1 | (lang dune 3.12) 2 | 3 | (name nomad) 4 | 5 | (generate_opam_files true) 6 | 7 | (source 8 | (github suri-framework/nomad)) 9 | 10 | (authors "Leandro Ostera ") 11 | 12 | (maintainers "Leandro Ostera ") 13 | 14 | (license MIT) 15 | 16 | (package 17 | (name nomad) 18 | (synopsis "A Web server for Trail applications") 19 | (description "Nomad is a web server for the Riot scheduler written entirely in OCaml and is built atop the Atacama connection pool. It aims to be an Application-layer for Trail, implementing: HTTP/1.x, HTTP/2, and WebSockets. It is written with a big focus on clarity.") 20 | (depends 21 | (atacama (>= "0.0.1")) 22 | (bitstring (>= "4.1.0")) 23 | (decompress (>= "1.5.3")) 24 | (digestif (>= "1.1.4")) 25 | (httpaf (>= "0.7.1")) 26 | (ocaml (>= "5.1.0")) 27 | (ppx_bitstring (>= "4.1.0")) 28 | (riot (>= "0.0.1")) 29 | (telemetry (>= "0.0.1")) 30 | (trail (>= "0.0.1")) 31 | (uutf (>= "1.0.3"))) 32 | (tags 33 | (riot trail http https websocket ws web server suri))) 34 | -------------------------------------------------------------------------------- /examples/dune: -------------------------------------------------------------------------------- 1 | (executables 2 | (names hello_world) 3 | (preprocess 4 | (pps bytestring.ppx)) 5 | (libraries trail nomad)) 6 | -------------------------------------------------------------------------------- /examples/hello_world.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | 3 | module Echo_server = struct 4 | include Trail.Sock.Default 5 | 6 | type args = unit 7 | type state = int 8 | 9 | let init _args = `ok 0 10 | 11 | let handle_frame frame _conn state = 12 | Logger.info (fun f -> f "handling frame: %a" Trail.Frame.pp frame); 13 | `push ([ frame ], state) 14 | end 15 | 16 | let trail = 17 | let open Trail in 18 | let open Router in 19 | [ 20 | use (module Logger) Logger.(args ~level:Debug ()); 21 | router 22 | [ 23 | get "/" (fun conn -> Conn.send_response `OK {%b|"hello world"|} conn); 24 | socket "/ws" (module Echo_server) (); 25 | scope "/api" 26 | [ 27 | get "/version" (fun conn -> 28 | Conn.send_response `OK {%b|"none"|} conn); 29 | get "/version" (fun conn -> 30 | Conn.send_response `OK {%b|"none"|} conn); 31 | get "/version" (fun conn -> 32 | Conn.send_response `OK {%b|"none"|} conn); 33 | ]; 34 | ]; 35 | ] 36 | 37 | [@@@warning "-8"] 38 | 39 | let () = 40 | Riot.run @@ fun () -> 41 | Logger.set_log_level (Some Info); 42 | let (Ok _) = Logger.start () in 43 | sleep 0.1; 44 | let port = 2118 in 45 | let handler = Nomad.trail trail in 46 | let (Ok pid) = Nomad.start_link ~port ~handler () in 47 | Logger.info (fun f -> f "Listening on 0.0.0.0:%d" port); 48 | wait_pids [ pid ] 49 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Minimal composable server framework for Riot."; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | 7 | atacama = { 8 | url = "github:suri-framework/atacama"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | inputs.riot.follows = "riot"; 11 | inputs.telemetry.follows = "telemetry"; 12 | }; 13 | 14 | trail = { 15 | url = "github:suri-framework/trail"; 16 | inputs.nixpkgs.follows = "nixpkgs"; 17 | inputs.atacama.follows = "atacama"; 18 | inputs.riot.follows = "riot"; 19 | }; 20 | 21 | riot = { 22 | url = "github:riot-ml/riot"; 23 | inputs.nixpkgs.follows = "nixpkgs"; 24 | inputs.telemetry.follows = "telemetry"; 25 | }; 26 | 27 | telemetry = { 28 | url = "github:leostera/telemetry"; 29 | inputs.nixpkgs.follows = "nixpkgs"; 30 | }; 31 | }; 32 | 33 | outputs = inputs@{ flake-parts, ... }: 34 | flake-parts.lib.mkFlake { inherit inputs; } { 35 | systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; 36 | perSystem = { config, self', inputs', pkgs, system, ... }: 37 | let 38 | inherit (pkgs) ocamlPackages mkShell; 39 | inherit (ocamlPackages) buildDunePackage; 40 | version = "0.0.1+dev"; 41 | in 42 | { 43 | devShells = { 44 | default = mkShell { 45 | buildInputs = with ocamlPackages; [ 46 | dune_3 47 | ocaml 48 | utop 49 | ocamlformat 50 | ]; 51 | inputsFrom = [ self'.packages.default ]; 52 | packages = builtins.attrValues { 53 | inherit (ocamlPackages) ocaml-lsp ocamlformat-rpc-lib; 54 | }; 55 | }; 56 | }; 57 | 58 | packages = { 59 | default = buildDunePackage { 60 | inherit version; 61 | pname = "nomad"; 62 | propagatedBuildInputs = with ocamlPackages; [ 63 | inputs'.atacama.packages.default 64 | bitstring 65 | decompress 66 | digestif 67 | httpaf 68 | ppx_bitstring 69 | inputs'.riot.packages.default 70 | inputs'.telemetry.packages.default 71 | inputs'.trail.packages.default 72 | uutf 73 | ]; 74 | src = ./.; 75 | }; 76 | }; 77 | }; 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /nomad.opam: -------------------------------------------------------------------------------- 1 | # This file is generated by dune, edit dune-project instead 2 | opam-version: "2.0" 3 | synopsis: "A Web server for Trail applications" 4 | description: 5 | "Nomad is a web server for the Riot scheduler written entirely in OCaml and is built atop the Atacama connection pool. It aims to be an Application-layer for Trail, implementing: HTTP/1.x, HTTP/2, and WebSockets. It is written with a big focus on clarity." 6 | maintainer: ["Leandro Ostera "] 7 | authors: ["Leandro Ostera "] 8 | license: "MIT" 9 | tags: ["riot" "trail" "http" "https" "websocket" "ws" "web" "server" "suri"] 10 | homepage: "https://github.com/suri-framework/nomad" 11 | bug-reports: "https://github.com/suri-framework/nomad/issues" 12 | depends: [ 13 | "dune" {>= "3.12"} 14 | "atacama" {>= "0.0.1"} 15 | "bitstring" {>= "4.1.0"} 16 | "decompress" {>= "1.5.3"} 17 | "digestif" {>= "1.1.4"} 18 | "httpaf" {>= "0.7.1"} 19 | "ocaml" {>= "5.1.0"} 20 | "ppx_bitstring" {>= "4.1.0"} 21 | "riot" {>= "0.0.1"} 22 | "telemetry" {>= "0.0.1"} 23 | "trail" {>= "0.0.1"} 24 | "uutf" {>= "1.0.3"} 25 | "odoc" {with-doc} 26 | ] 27 | build: [ 28 | ["dune" "subst"] {dev} 29 | [ 30 | "dune" 31 | "build" 32 | "-p" 33 | name 34 | "-j" 35 | jobs 36 | "@install" 37 | "@runtest" {with-test} 38 | "@doc" {with-doc} 39 | ] 40 | ] 41 | dev-repo: "git+https://github.com/suri-framework/nomad.git" 42 | -------------------------------------------------------------------------------- /nomad/adapter.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | open Trail 3 | 4 | open Riot.Logger.Make (struct 5 | let namespace = [ "nomad"; "http1"; "adapter" ] 6 | end) 7 | 8 | let ( let* ) = Result.bind 9 | 10 | let rec split ?(left = {%b||}) str = 11 | match%b str with 12 | | {| "\r\n"::bytes, rest::bytes |} -> [ left; rest ] 13 | | {| c::utf8, rest::bytes |} -> split ~left:(Bytestring.join c left) rest 14 | 15 | let deflate_string str = 16 | let i = De.bigstring_create De.io_buffer_size in 17 | let o = De.bigstring_create De.io_buffer_size in 18 | let w = De.Lz77.make_window ~bits:15 in 19 | let q = De.Queue.create 0x1000 in 20 | let r = Buffer.create 0x1000 in 21 | let p = ref 0 in 22 | let refill buf = 23 | let len = min (String.length str - !p) De.io_buffer_size in 24 | Bigstringaf.blit_from_string str ~src_off:!p buf ~dst_off:0 ~len; 25 | p := !p + len; 26 | len 27 | in 28 | let flush buf len = 29 | let str = Bigstringaf.substring buf ~off:0 ~len in 30 | Buffer.add_string r str 31 | in 32 | Zl.Higher.compress ~level:9 ~w ~q ~refill ~flush i o; 33 | Buffer.contents r 34 | 35 | let gzip_string str = 36 | let time () = 2112l in 37 | let i = De.bigstring_create De.io_buffer_size in 38 | let o = De.bigstring_create De.io_buffer_size in 39 | let w = De.Lz77.make_window ~bits:16 in 40 | let q = De.Queue.create 0x1000 in 41 | let r = Buffer.create 0x1000 in 42 | let p = ref 0 in 43 | let cfg = Gz.Higher.configuration Gz.Unix time in 44 | let refill buf = 45 | let len = min (String.length str - !p) De.io_buffer_size in 46 | Bigstringaf.blit_from_string str ~src_off:!p buf ~dst_off:0 ~len; 47 | p := !p + len; 48 | len 49 | in 50 | let flush buf len = 51 | let str = Bigstringaf.substring buf ~off:0 ~len in 52 | Buffer.add_string r str 53 | in 54 | Gz.Higher.compress ~w ~q ~level:9 ~refill ~flush () cfg i o; 55 | Buffer.contents r 56 | 57 | let gzip buf = gzip_string (Bytestring.to_string buf) |> Bytestring.of_string 58 | 59 | let deflate buf = 60 | let str = deflate_string (Bytestring.to_string buf) in 61 | str |> Bytestring.of_string 62 | 63 | let has_custom_content_encoding (res : Response.t) = 64 | Http.Header.get res.headers "content-encoding" |> Option.is_some 65 | 66 | let has_weak_etag (res : Response.t) = 67 | match Http.Header.get res.headers "etag" with 68 | | Some etag -> String.starts_with ~prefix:"W/" etag 69 | | None -> false 70 | 71 | let has_strong_etag (res : Response.t) = 72 | match Http.Header.get res.headers "etag" with 73 | | Some etag -> not (String.starts_with ~prefix:"W/" etag) 74 | | None -> false 75 | 76 | let has_no_transform (res : Response.t) = 77 | match Http.Header.get res.headers "cache-control" with 78 | | Some "no-transform" -> true 79 | | _ -> false 80 | 81 | let maybe_compress (req : Request.t) buf = 82 | if Bytestring.length buf = 0 then (None, None) 83 | else 84 | let accepted_encodings = 85 | Http.Header.get req.headers "accept-encoding" 86 | |> Option.map (fun enc -> String.split_on_char ',' enc) 87 | |> Option.value ~default:[] |> List.map String.trim 88 | in 89 | let accepts_deflate = List.mem "deflate" accepted_encodings in 90 | let accepts_gzip = List.mem "gzip" accepted_encodings in 91 | let accepts_x_gzip = List.mem "x-gzip" accepted_encodings in 92 | if accepts_deflate then (Some (deflate buf), Some "deflate") 93 | else if accepts_gzip then (Some (gzip buf), Some "gzip") 94 | else if accepts_x_gzip then (Some (gzip buf), Some "x-gzip") 95 | else (Some buf, None) 96 | 97 | let send conn (req : Request.t) (res : Response.t) = 98 | if req.version = `HTTP_1_0 && res.status = `Continue then () 99 | else 100 | let body, encoding = 101 | if 102 | has_custom_content_encoding res 103 | || has_strong_etag res || has_no_transform res 104 | then (Some res.body, None) 105 | else maybe_compress req res.body 106 | in 107 | let headers = 108 | match encoding with 109 | | Some encoding -> Http.Header.add res.headers "content-encoding" encoding 110 | | None -> res.headers 111 | in 112 | let headers = Http.Header.add headers "vary" "accept-encoding" in 113 | let is_chunked = 114 | Http.Header.get_transfer_encoding headers = Http.Transfer.Chunked 115 | in 116 | 117 | let body_len = 118 | Option.map Bytestring.length body 119 | |> Option.value ~default:0 |> Int.to_string 120 | in 121 | let headers = 122 | let content_length = Http.Header.get headers "content-length" in 123 | match (content_length, res.status) with 124 | | _, (`No_content | `Not_modified) -> 125 | Http.Header.remove headers "content-length" 126 | | None, _ when not is_chunked -> 127 | Http.Header.replace headers "content-length" body_len 128 | | _ -> headers 129 | in 130 | 131 | let headers = 132 | match Http.Header.get headers "date" with 133 | | Some _ -> headers 134 | | None -> 135 | let now = Ptime_clock.now () in 136 | let (y, mon, d), ((h, m, s), _ns) = Ptime.to_date_time now in 137 | let day = 138 | match Ptime.weekday ?tz_offset_s:None now with 139 | | `Sat -> "Sat" 140 | | `Fri -> "Fri" 141 | | `Mon -> "Mon" 142 | | `Wed -> "Wed" 143 | | `Tue -> "Tue" 144 | | `Sun -> "Sun" 145 | | `Thu -> "Thu" 146 | in 147 | let[@warning "-8"] mon = 148 | match mon with 149 | | 0 -> "Jan" 150 | | 1 -> "Feb" 151 | | 2 -> "Mar" 152 | | 3 -> "Apr" 153 | | 4 -> "May" 154 | | 5 -> "Jun" 155 | | 6 -> "Jul" 156 | | 7 -> "Aug" 157 | | 8 -> "Sep" 158 | | 9 -> "Oct" 159 | | 10 -> "Nov" 160 | | 11 -> "Dec" 161 | in 162 | let now = 163 | Format.sprintf "%s, %02d %s %d %02d:%02d:%02d GMT" day d mon y h m s 164 | in 165 | trace (fun f -> f "Adding date header: %S" now); 166 | Http.Header.add headers "date" now 167 | in 168 | 169 | let body = 170 | if 171 | req.meth = `HEAD || res.status = `No_content 172 | || res.status = `Not_modified 173 | then None 174 | else body 175 | in 176 | 177 | let buf = 178 | let version = 179 | res.version |> Http.Version.to_string |> Httpaf.Version.of_string 180 | in 181 | let status = res.status |> Http.Status.to_int |> Httpaf.Status.of_code in 182 | let headers = headers |> Http.Header.to_list |> Httpaf.Headers.of_list in 183 | let res = Httpaf.Response.create ~version ~headers status in 184 | let buf = Faraday.create (1024 * 4) in 185 | Httpaf.Httpaf_private.Serialize.write_response buf res; 186 | 187 | (match body with 188 | | Some body -> Faraday.write_string buf (Bytestring.to_string body) 189 | | _ -> ()); 190 | 191 | let s = Faraday.serialize_to_string buf in 192 | Bytestring.of_string s 193 | in 194 | 195 | Atacama.Connection.send conn buf |> Result.get_ok 196 | 197 | let send_chunk conn (req : Request.t) buf = 198 | if req.meth = `HEAD then () 199 | else 200 | let chunk = 201 | Format.sprintf "%x\r\n%s\r\n" (Bytestring.length buf) 202 | (Bytestring.to_string buf) 203 | in 204 | trace (fun f -> f "sending chunk: %S" chunk); 205 | let chunk = Bytestring.of_string chunk in 206 | let _ = Atacama.Connection.send conn chunk in 207 | () 208 | 209 | let close_chunk conn = 210 | let chunk = Bytestring.of_string "0\r\n\r\n" in 211 | let _ = Atacama.Connection.send conn chunk in 212 | () 213 | 214 | let send_file _conn (_req : Request.t) (_res : Response.t) ?off:_ ?len:_ ~path:_ 215 | () = 216 | (* 217 | let len = 218 | match len with 219 | | Some len -> len 220 | | None -> 221 | let stat = File.stat path in 222 | stat.st_size 223 | in 224 | let headers = 225 | Http.Header.replace res.headers "content-length" (Int.to_string len) 226 | in 227 | let res = { res with headers; body = Bytestring.empty } in 228 | let _ = send conn req res in 229 | if 230 | req.meth != `HEAD && res.status != `No_content 231 | && res.status != `Not_modified 232 | then 233 | let _ = Atacama.Connection.send_file conn ?off ~len (File.open_read path) in 234 | () 235 | *) 236 | () 237 | 238 | let close conn (req : Request.t) (res : Response.t) = 239 | if req.meth = `HEAD then () 240 | else if res.status = `No_content then () 241 | else 242 | let _ = Atacama.Connection.send conn (Bytestring.of_string "0\r\n\r\n") in 243 | () 244 | 245 | open Trail 246 | 247 | let rec read_body ?limit ?(read_size = 1_024 * 1_024) conn (req : Request.t) = 248 | match Request.body_encoding req with 249 | | Http.Transfer.Chunked -> ( 250 | trace (fun f -> f "reading chunked body"); 251 | match 252 | read_chunked_body ~read_size ~buffer:req.buffer ~body:Bytestring.empty 253 | conn req 254 | with 255 | | Ok (body, buffer) -> 256 | trace (fun f -> 257 | f "read chunked_body: buffer=%d" (Bytestring.length buffer)); 258 | Adapter.Ok ({ req with buffer }, body) 259 | | Error reason -> Adapter.Error (req, reason)) 260 | | _ -> ( 261 | trace (fun f -> f "reading content-length body"); 262 | match read_content_length_body ?limit ~read_size conn req with 263 | | Ok (body, buffer, body_remaining) -> 264 | trace (fun f -> 265 | f "read chunked_body: body_remaning=%d buffer=%d" body_remaining 266 | (Bytestring.length buffer)); 267 | let req = { req with buffer; body_remaining } in 268 | if body_remaining = 0 && Bytestring.length buffer = 0 then ( 269 | trace (fun f -> f "read chunked_body: ok"); 270 | let req = { req with buffer; body_remaining = -1 } in 271 | Adapter.Ok (req, body)) 272 | else ( 273 | trace (fun f -> f "read chunked_body: more"); 274 | Adapter.More (req, body)) 275 | | Error reason -> Adapter.Error (req, reason)) 276 | 277 | and read_chunked_body ~read_size ~buffer ~body conn req = 278 | let parts = split buffer in 279 | trace (fun f -> f "body_size: %d" (Bytestring.length body)); 280 | trace (fun f -> f "buffer: %d" (Bytestring.length buffer)); 281 | trace (fun f -> 282 | f "total_read: %d" (Bytestring.length buffer + Bytestring.length body)); 283 | trace (fun f -> 284 | match parts with 285 | | size :: _ -> f "chunk_size: 0x%s" (Bytestring.to_string size) 286 | | _ -> ()); 287 | 288 | match parts with 289 | | [ zero; _ ] when String.equal (Bytestring.to_string zero) "0" -> 290 | trace (fun f -> f "read_chunked_body: last chunk!"); 291 | Ok (body, buffer) 292 | | [ chunk_size; chunk_data ] -> ( 293 | let chunk_size = 294 | Int64.(of_string ("0x" ^ Bytestring.to_string chunk_size) |> to_int) 295 | in 296 | trace (fun f -> f "read_chunked_body: chunk_size=%d" chunk_size); 297 | let binstr_data = Bytestring.to_string chunk_data in 298 | trace (fun f -> 299 | f "read_chunked_body: (%d bytes)" (String.length binstr_data)); 300 | let binstr_data = binstr_data |> Bitstring.bitstring_of_string in 301 | match%bitstring binstr_data with 302 | | {| next_chunk : (chunk_size * 8) : string ; 303 | "\r\n" : 2 * 8 : string ; 304 | rest : -1 : bitstring |} 305 | -> 306 | trace (fun f -> f "read_chunked_body: read full chunk"); 307 | trace (fun f -> 308 | f "read_chunked_body: rest=%d" (Bitstring.bitstring_length rest)); 309 | let rest = 310 | Bytestring.of_string (Bitstring.string_of_bitstring rest) 311 | in 312 | let next_chunk = Bytestring.of_string next_chunk in 313 | let body = Bytestring.join body next_chunk in 314 | read_chunked_body ~read_size ~buffer:rest ~body conn req 315 | | {| _ |} -> 316 | let left_to_read = chunk_size - Bytestring.length chunk_data in 317 | trace (fun f -> 318 | f "read_chunked_body: reading more data left_to_read=%d" 319 | left_to_read); 320 | let* chunk = 321 | if left_to_read > 0 then read ~to_read:left_to_read ~read_size conn 322 | else Atacama.Connection.receive conn 323 | in 324 | let buffer = Bytestring.join buffer chunk in 325 | read_chunked_body ~read_size ~buffer ~body conn req) 326 | | _ -> 327 | trace (fun f -> f "read_chunked_body: need more data"); 328 | let* chunk = Atacama.Connection.receive conn in 329 | let buffer = Bytestring.join buffer chunk in 330 | read_chunked_body ~read_size ~buffer ~body conn req 331 | 332 | and read_content_length_body ?limit ~read_size conn req = 333 | let buffer = req.buffer in 334 | let limit = Option.value ~default:req.body_remaining limit in 335 | let to_read = limit - Bytestring.length buffer in 336 | trace (fun f -> 337 | f "read_content_length_body: up to limit=%d with preread_buffer=%d" limit 338 | (Bytestring.length buffer)); 339 | match req.body_remaining with 340 | | n when n < 0 || to_read < 0 -> 341 | trace (fun f -> f "read_content_length_body: excess body"); 342 | Error `Excess_body_read 343 | | 0 when Bytestring.length buffer >= limit -> 344 | trace (fun f -> f "read_content_length_body: can answer with buffer"); 345 | let len = Int.min limit (Bytestring.length buffer) in 346 | let body = Bytestring.sub ~off:0 ~len buffer in 347 | Ok (body, Bytestring.empty, 0) 348 | | remaining_bytes -> 349 | let to_read = 350 | Int.min (limit - Bytestring.length buffer) remaining_bytes 351 | in 352 | trace (fun f -> f "read_content_length_body: need to read %d" to_read); 353 | let* chunk = read ~to_read ~read_size conn in 354 | let body = Bytestring.join buffer chunk in 355 | let body_remaining = remaining_bytes - Bytestring.length body in 356 | Ok (body, Bytestring.empty, body_remaining) 357 | 358 | and read ~read_size ~to_read ?(buffer = Bytestring.empty) conn = 359 | if to_read = 0 then Ok Bytestring.empty 360 | else 361 | let* chunk = Atacama.Connection.receive ~limit:to_read ~read_size conn in 362 | let remaining_bytes = to_read - Bytestring.length chunk in 363 | let buffer = Bytestring.join buffer chunk in 364 | trace (fun f -> f "read: remaining_bytes %d" remaining_bytes); 365 | trace (fun f -> f "read: buffer=%d" (Bytestring.length buffer)); 366 | if remaining_bytes > 0 then 367 | read ~read_size ~to_read:remaining_bytes ~buffer conn 368 | else Ok buffer 369 | -------------------------------------------------------------------------------- /nomad/config.ml: -------------------------------------------------------------------------------- 1 | type t = { 2 | max_line_request_length : int; 3 | max_header_count : int; 4 | max_header_length : int; 5 | request_receive_timeout : int64; 6 | } 7 | 8 | let make ?(max_line_request_length = 8000) ?(max_header_count = 100) 9 | ?(max_header_length = 5000) ?(request_receive_timeout = 1_000_000L) () = 10 | { 11 | max_line_request_length; 12 | max_header_count; 13 | max_header_length; 14 | request_receive_timeout; 15 | } 16 | -------------------------------------------------------------------------------- /nomad/connection_handler.ml: -------------------------------------------------------------------------------- 1 | open Atacama.Handler 2 | include Atacama.Handler.Default 3 | 4 | let ( let* ) = Result.bind 5 | 6 | type state = { 7 | enabled_protocols : [ `http1 | `http2 ] list; 8 | handler : Handler.t; 9 | config : Config.t; 10 | } 11 | 12 | type error = unit 13 | 14 | let pp_err _fmt _ = () 15 | 16 | let make ?(enabled_protocols = [ `http1; `http2 ]) ?(config = Config.make ()) 17 | ~handler () = 18 | { handler; config; enabled_protocols } 19 | 20 | let handle_connection conn ({ enabled_protocols; handler; config; _ } as state) 21 | = 22 | match 23 | Negotiator.negotiated_protocol ~enabled_protocols ~config conn handler 24 | with 25 | | Ok new_handler -> Switch new_handler 26 | | _ -> Close state 27 | -------------------------------------------------------------------------------- /nomad/dune: -------------------------------------------------------------------------------- 1 | (library 2 | (public_name nomad) 3 | (name nomad) 4 | (preprocess 5 | (pps ppx_bitstring bytestring.ppx)) 6 | (libraries 7 | trail 8 | atacama 9 | riot 10 | telemetry 11 | bitstring 12 | httpaf 13 | http 14 | digestif 15 | uutf 16 | decompress.gz 17 | decompress.zl)) 18 | -------------------------------------------------------------------------------- /nomad/events.ml: -------------------------------------------------------------------------------- 1 | type Telemetry.event += Request_received of { req : Http.Request.t } [@@unboxed] 2 | 3 | let request_received req = Telemetry.emit (Request_received { req }) 4 | -------------------------------------------------------------------------------- /nomad/frame.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | 3 | let is_flag_set n flags = flags land (1 lsl n) = 1 4 | let is_flag_clear n flags = not (is_flag_set n flags) 5 | let has_ack_bit = is_flag_set 0 6 | let flags fs = List.fold_left (fun fs acc -> (fs lor acc) lsl 1) 0 fs 7 | let min_frame_size = 16_384 8 | let max_frame_size = 16_777_215 9 | let max_window_size = 2_147_483_647 10 | 11 | type stream_id = int 12 | 13 | module Continuation = struct 14 | type t = { 15 | stream_id : stream_id; 16 | end_headers : bool; 17 | fragment : Bytestring.t; 18 | } 19 | 20 | let has_headers_bit = is_flag_set 2 21 | let frame_type = 0x9 22 | 23 | let make ~flags ~stream_id ~payload = 24 | if stream_id = 0 then 25 | Error (`protocol_error `continuation_frame_with_zero_stream_id) 26 | else 27 | let fragment = 28 | Bytestring.of_string (Bitstring.string_of_bitstring payload) 29 | in 30 | Ok { stream_id; fragment; end_headers = has_headers_bit flags } 31 | 32 | let[@tail_mod_cons] rec parts t = 33 | let length = Bytestring.length t.fragment in 34 | if length <= max_frame_size then 35 | let data = 36 | Bytestring.to_string t.fragment |> Bitstring.bitstring_of_string 37 | in 38 | [ (frame_type, flags [ 2 ], t.stream_id, data) ] 39 | else 40 | let this_frame = 41 | let sub = Bytestring.sub ~off:0 ~len:max_frame_size t.fragment in 42 | Bytestring.to_string sub |> Bitstring.bitstring_of_string 43 | in 44 | let rest = 45 | let len = Bytestring.length t.fragment - max_frame_size in 46 | Bytestring.sub ~off:max_frame_size ~len t.fragment 47 | in 48 | let frame = (frame_type, 0x0, t.stream_id, this_frame) in 49 | frame :: parts { t with fragment = rest } 50 | end 51 | 52 | module Data = struct 53 | type t = { 54 | stream_id : stream_id; 55 | data : Bytestring.t; 56 | end_stream : bool; 57 | flags : int; 58 | } 59 | 60 | let frame_type = 0x0 61 | let has_end_stream_bit = is_flag_set 0 62 | let has_padding_bit = is_flag_set 3 63 | 64 | let padded_data ~flags ~stream_id ~payload = 65 | match%bitstring payload with 66 | | {| padding : 8 ; rest : -1 : string |} when String.length rest >= padding 67 | -> 68 | let data = String.sub rest 0 (String.length rest - padding) in 69 | let data = Bytestring.of_string data in 70 | Ok { stream_id; data; end_stream = has_end_stream_bit flags; flags } 71 | | {| _ |} -> Error (`protocol_error `data_frame_with_invalid_padding_length) 72 | 73 | let make ~flags ~stream_id ~payload = 74 | if stream_id = 0 then 75 | Error (`protocol_error `data_frame_with_zero_stream_id) 76 | else if has_padding_bit flags then padded_data ~flags ~stream_id ~payload 77 | else 78 | let data = Bytestring.of_string (Bitstring.string_of_bitstring payload) in 79 | Ok { stream_id; data; end_stream = has_end_stream_bit flags; flags } 80 | 81 | let[@tail_mod_cons] rec parts t = 82 | let length = Bytestring.length t.data in 83 | if length <= max_frame_size then 84 | let flags = flags (if t.end_stream then [ 0 ] else []) in 85 | let data = Bytestring.to_string t.data |> Bitstring.bitstring_of_string in 86 | [ (frame_type, flags, t.stream_id, data) ] 87 | else 88 | let this_frame = 89 | let sub = Bytestring.sub ~off:0 ~len:max_frame_size t.data in 90 | Bytestring.to_string sub |> Bitstring.bitstring_of_string 91 | in 92 | let rest = 93 | let len = Bytestring.length t.data - max_frame_size in 94 | Bytestring.sub ~off:max_frame_size ~len t.data 95 | in 96 | let frame = (frame_type, 0x0, t.stream_id, this_frame) in 97 | frame :: parts { t with data = rest } 98 | end 99 | 100 | module Settings = struct 101 | type settings = { 102 | header_table_size : int; 103 | initial_window_size : int; 104 | max_frame_size : int; 105 | max_header_list_size : int; 106 | max_concurrent_streams : int; 107 | } 108 | 109 | type t = { stream_id : stream_id; ack : bool; settings : settings } 110 | 111 | let pp fmt t = 112 | Format.fprintf fmt 113 | "stream_id=%d; ack=%b; header_table_size=%d; initial_window_size=%d; \ 114 | max_frame_size=%d; max_header_list_size=%d; max_concurrent_streams=%d" 115 | t.stream_id t.ack t.settings.header_table_size 116 | t.settings.initial_window_size t.settings.max_frame_size 117 | t.settings.max_header_list_size t.settings.max_concurrent_streams 118 | 119 | let default_settings = 120 | { 121 | header_table_size = 4_096; 122 | initial_window_size = 65_535; 123 | max_frame_size; 124 | max_header_list_size = Int.max_int; 125 | max_concurrent_streams = Int.max_int; 126 | } 127 | 128 | let empty = { stream_id = 0; ack = true; settings = default_settings } 129 | 130 | let parse_settings ~stream_id (payload : Bitstring.t) = 131 | let settings = 132 | payload 133 | |> Stream.unfold 134 | (function%bitstring 135 | | {| setting : 16 : int ; value : 32 : int ; rest : -1 : bitstring |} 136 | -> 137 | Some ((setting, Int32.to_int value), rest) 138 | | {| _ |} -> None) 139 | |> Stream.reduce_while (Ok default_settings) (fun (setting, value) acc -> 140 | match (setting, acc) with 141 | | 0x01, Ok settings -> 142 | `continue (Ok { settings with header_table_size = value }) 143 | | 0x02, Ok settings when value = 0 || value = 1 -> 144 | `continue (Ok settings) 145 | | 0x02, _ -> 146 | `halt (Error (`settings_error `invalid_enable_push_value)) 147 | | 0x03, Ok settings -> 148 | `continue (Ok { settings with max_concurrent_streams = value }) 149 | | 0x04, Ok _settings when value > max_window_size -> 150 | `halt (Error (`settings_error (`window_size_too_large value))) 151 | | 0x04, Ok settings -> 152 | `continue (Ok { settings with initial_window_size = value }) 153 | | 0x05, Ok _settings when value < min_frame_size -> 154 | `halt (Error (`settings_error (`frame_size_too_small value))) 155 | | 0x05, Ok _settings when value > max_frame_size -> 156 | `halt (Error (`settings_error (`frame_size_too_large value))) 157 | | 0x05, Ok settings -> 158 | `continue (Ok { settings with max_frame_size = value }) 159 | | 0x06, Ok settings -> 160 | `continue (Ok { settings with max_header_list_size = value }) 161 | | _, Ok settings -> `continue (Ok settings) 162 | | _, Error reason -> `halt (Error reason)) 163 | in 164 | match settings with 165 | | Ok settings -> Ok { stream_id; ack = true; settings } 166 | | Error reason -> Error reason 167 | 168 | let make ~flags ~stream_id ~payload = 169 | if stream_id != 0 then 170 | Error (`protocol_error (`invalid_settings_frame_with_stream_id stream_id)) 171 | else if not (has_ack_bit flags) then parse_settings ~stream_id payload 172 | else Ok { stream_id; ack = false; settings = default_settings } 173 | 174 | let parts t = 175 | let payload = 176 | [ 177 | (t.settings.header_table_size, 4_096, 0x01); 178 | (t.settings.max_concurrent_streams, Int.max_int, 0x03); 179 | (t.settings.initial_window_size, 65_535, 0x04); 180 | (t.settings.max_frame_size, max_frame_size, 0x05); 181 | (t.settings.max_header_list_size, Int.max_int, 0x06); 182 | ] 183 | |> List.map (fun (value, default, code) -> 184 | if value = default then Bitstring.empty_bitstring 185 | else {%bitstring| code : 16; (Int32.of_int value) : 32 |}) 186 | |> Bitstring.concat 187 | in 188 | [ (0x4, 0x0, 0, payload) ] 189 | end 190 | 191 | module Headers = struct 192 | type t = { 193 | stream_id : stream_id; 194 | end_stream : bool; 195 | end_headers : bool; 196 | exclusive_dependency : bool; 197 | stream_dependency : stream_id option; 198 | weight : int; 199 | fragment : Bytestring.t; 200 | } 201 | 202 | let pp fmt t = 203 | Format.fprintf fmt 204 | "stream_id=%d end_stream=%b end_headers=%b exclusive_dependency=%b \ 205 | stream_dependency=%d weight=%d fragment=%S" 206 | t.stream_id t.end_stream t.end_headers t.exclusive_dependency 207 | (Option.value ~default:0 t.stream_dependency) 208 | t.weight 209 | (Bytestring.to_string t.fragment) 210 | 211 | let has_end_stream_bit = is_flag_set 0 212 | let has_end_headers_bit = is_flag_set 2 213 | let has_padding_bit = is_flag_set 3 214 | let has_priority_bit = is_flag_set 5 215 | let clear_priority_bit = is_flag_clear 5 216 | 217 | let do_make ~flags ~stream_id ~payload = 218 | let end_stream = has_end_stream_bit flags in 219 | let end_headers = has_end_headers_bit flags in 220 | match%bitstring payload with 221 | (* priority and padding *) 222 | | {| padding_length : 8; 223 | exclusive_dependency : 1; 224 | stream_dependency : 31; 225 | weight : 8; 226 | rest : -1 : string |} 227 | when has_padding_bit flags && has_priority_bit flags 228 | && String.length rest >= padding_length -> 229 | Ok 230 | { 231 | stream_id; 232 | end_stream; 233 | end_headers; 234 | exclusive_dependency; 235 | stream_dependency = Some stream_dependency; 236 | weight; 237 | fragment = 238 | Bytestring.of_string 239 | (String.sub rest 0 (String.length rest - padding_length)); 240 | } 241 | (* padding but no priority *) 242 | | {| padding_length : 8; rest : -1 : string |} 243 | when has_padding_bit flags && clear_priority_bit flags 244 | && String.length rest >= padding_length -> 245 | Ok 246 | { 247 | stream_id; 248 | end_stream; 249 | end_headers; 250 | exclusive_dependency = false; 251 | stream_dependency = None; 252 | weight = 0; 253 | fragment = 254 | Bytestring.of_string 255 | (String.sub rest 0 (String.length rest - padding_length)); 256 | } 257 | (* other padding cases *) 258 | | {| _padding_length : 8 ; _ : -1 : string |} when has_padding_bit flags -> 259 | Error (`protocol_error `headers_frame_with_invalid_padding_length) 260 | (* priority but no padding *) 261 | | {| exclusive_dependency : 1; 262 | stream_dependency : 31; 263 | weight : 8; 264 | fragment : -1 : string |} 265 | when has_priority_bit flags -> 266 | Ok 267 | { 268 | stream_id; 269 | end_stream; 270 | end_headers; 271 | exclusive_dependency; 272 | stream_dependency = Some stream_dependency; 273 | weight; 274 | fragment = Bytestring.of_string fragment; 275 | } 276 | (* no priority and no padding *) 277 | | {| fragment : -1 : string |} -> 278 | Ok 279 | { 280 | stream_id; 281 | end_stream; 282 | end_headers; 283 | exclusive_dependency = false; 284 | stream_dependency = None; 285 | weight = 0; 286 | fragment = Bytestring.of_string fragment; 287 | } 288 | 289 | let make ~flags ~stream_id ~payload = 290 | if stream_id = 0 then 291 | Error (`protocol_error `headers_frame_with_zero_stream_id) 292 | else do_make ~flags ~stream_id ~payload 293 | 294 | let parts t = 295 | let bits = if t.end_stream then [ 0 ] else [] in 296 | let fragment_length = Bytestring.length t.fragment in 297 | 298 | if fragment_length <= max_frame_size then 299 | let data = 300 | Bytestring.to_string t.fragment |> Bitstring.bitstring_of_string 301 | in 302 | [ (0x1, flags (2 :: bits), t.stream_id, data) ] 303 | else 304 | let this_frame = 305 | let sub = Bytestring.sub ~off:0 ~len:max_frame_size t.fragment in 306 | Bytestring.to_string sub |> Bitstring.bitstring_of_string 307 | in 308 | let rest = 309 | let len = Bytestring.length t.fragment - max_frame_size in 310 | Bytestring.sub ~off:max_frame_size ~len t.fragment 311 | in 312 | let frame = (0x1, 0x0, t.stream_id, this_frame) in 313 | frame 314 | :: Continuation.( 315 | parts 316 | { stream_id = t.stream_id; fragment = rest; end_headers = false }) 317 | end 318 | 319 | module Priority = struct 320 | type t = { 321 | stream_id : stream_id; 322 | dependent_stream_id : stream_id; 323 | weight : int; 324 | } 325 | 326 | let make ~flags:_ ~stream_id ~payload = 327 | match%bitstring payload with 328 | | {| _rsv : 1 ; 329 | dependent_stream_id : 31 ; 330 | weight : 8 |} 331 | when stream_id != 0 -> 332 | Ok { stream_id; dependent_stream_id; weight } 333 | | {| _ |} when stream_id = 0 -> 334 | Error (`protocol_error `priority_frame_with_zero_stream_id) 335 | | {| _ |} -> Error (`protocol_error `invalid_payload_size_in_priority_frame) 336 | 337 | let parts t = 338 | [ 339 | ( 0x2, 340 | 0x0, 341 | t.stream_id, 342 | {%bitstring| 0 : 1; t.dependent_stream_id : 31 ; t.weight : 8 |} ); 343 | ] 344 | end 345 | 346 | module Rst_stream = struct 347 | (* 348 | no_error: 0x0, 349 | protocol_error: 0x1, 350 | internal_error: 0x2, 351 | flow_control_error: 0x3, 352 | settings_timeout: 0x4, 353 | stream_closed: 0x5, 354 | frame_size_error: 0x6, 355 | refused_stream: 0x7, 356 | cancel: 0x8, 357 | compression_error: 0x9, 358 | connect_error: 0xA, 359 | enhance_your_calm: 0xB, 360 | inadequate_security: 0xC, 361 | http_1_1_requires: 0xD 362 | *) 363 | type t = { stream_id : stream_id; error_code : int32 } 364 | 365 | let make ~flags:_ ~stream_id ~payload = 366 | match%bitstring payload with 367 | | {| error_code : 32 |} when stream_id != 0 -> Ok { stream_id; error_code } 368 | | {| _ |} when stream_id = 0 -> 369 | Error (`protocol_error `rst_stream_frame_with_zero_stream_id) 370 | | {| _ |} -> Error (`protocol_error `invalid_payload_size_in_rst_stream) 371 | 372 | let parts t = [ (0x3, 0x0, t.stream_id, {%bitstring| t.error_code : 32 |}) ] 373 | end 374 | 375 | module Push_promise = struct 376 | type t = unit 377 | 378 | let make ~flags:_ ~stream_id:_ ~payload:_ = 379 | Error (`protocol_error `push_promise_frame_received) 380 | end 381 | 382 | module Ping = struct 383 | type t = { ack : bool; payload : Bytestring.t } 384 | 385 | let has_ack_bit = is_flag_set 0 386 | 387 | let make ~flags ~stream_id ~payload = 388 | match%bitstring payload with 389 | | {| payload : 8 : string |} when stream_id = 0 -> 390 | Ok { ack = has_ack_bit flags; payload = Bytestring.of_string payload } 391 | | {| _ |} when stream_id != 0 -> 392 | Error (`protocol_error `invalid_stream_id_in_ping_frame) 393 | | {| _ |} -> Error (`protocol_error `invalid_payload_size_in_ping_frame) 394 | 395 | let parts t = 396 | let flags = if t.ack then flags [ 0 ] else 0 in 397 | let payload = 398 | Bytestring.to_string t.payload |> Bitstring.bitstring_of_string 399 | in 400 | [ (0x6, flags, 0, payload) ] 401 | end 402 | 403 | module Go_away = struct 404 | type t = { 405 | last_stream_id : stream_id; 406 | error_code : int32; 407 | debug_data : Bytestring.t; 408 | } 409 | 410 | let make ~flags:_ ~stream_id ~payload = 411 | match%bitstring payload with 412 | | {| _rsv : 1; last_stream_id : 31 ; error_code : 32; debug_data : -1 : string |} 413 | when stream_id = 0 -> 414 | Ok 415 | { 416 | last_stream_id; 417 | error_code; 418 | debug_data = Bytestring.of_string debug_data; 419 | } 420 | | {| _ |} when stream_id != 0 -> 421 | Error (`protocol_error `invalid_stream_id_in_goaway_frame) 422 | | {| _ |} -> Error (`protocol_error `invalid_payload_size_in_goaway_frame) 423 | 424 | let parts t = 425 | [ 426 | ( 0x7, 427 | 0x0, 428 | 0, 429 | Bitstring.concat 430 | [ 431 | {%bitstring| 0 : 1; t.last_stream_id : 31 ; t.error_code : 32 |}; 432 | Bitstring.bitstring_of_string (Bytestring.to_string t.debug_data); 433 | ] ); 434 | ] 435 | end 436 | 437 | module Window_update = struct 438 | type t = { stream_id : stream_id; size_increment : int } 439 | 440 | let make ~flags:_ ~stream_id ~payload = 441 | match%bitstring payload with 442 | | {| _rsv : 1 ; size_increment : 31 |} when size_increment = 0 -> 443 | Error (`protocol_error `invalid_window_update_size_increment) 444 | | {| _rsv : 1 ; size_increment : 31 |} -> Ok { stream_id; size_increment } 445 | | {| _ |} -> Error (`protocol_error `invalid_window_update_frame) 446 | 447 | let parts t = 448 | [ (0x8, 0, t.stream_id, {%bitstring| 0 : 1; t.size_increment : 31 |}) ] 449 | end 450 | 451 | type t = 452 | | Data of Data.t 453 | | Headers of Headers.t 454 | | Priority of Priority.t 455 | | Rst_stream of Rst_stream.t 456 | | Push_promise of Push_promise.t 457 | | Ping of Ping.t 458 | | Go_away of Go_away.t 459 | | Window_update of Window_update.t 460 | | Continuation of Continuation.t 461 | | Settings of Settings.t 462 | | Unknown of { stream_id : stream_id; flags : int; payload : Bytestring.t } 463 | 464 | let pp fmt t = 465 | match t with 466 | | Data _ -> Format.fprintf fmt "Data" 467 | | Headers t -> Format.fprintf fmt "Headers(%a)" Headers.pp t 468 | | Priority _ -> Format.fprintf fmt "Priority" 469 | | Rst_stream _ -> Format.fprintf fmt "Rst_stream" 470 | | Push_promise _ -> Format.fprintf fmt "Push_promise" 471 | | Ping _ -> Format.fprintf fmt "Ping" 472 | | Go_away _ -> Format.fprintf fmt "Go_away" 473 | | Window_update _ -> Format.fprintf fmt "Window_update" 474 | | Continuation _ -> Format.fprintf fmt "Continuation" 475 | | Settings t -> Format.fprintf fmt "Settings(%a)" Settings.pp t 476 | | Unknown _ -> Format.fprintf fmt "Unknown" 477 | 478 | let stream_id t = 479 | match t with 480 | | Data { stream_id; _ } -> stream_id 481 | | Headers { stream_id; _ } -> stream_id 482 | | Priority { stream_id; _ } -> stream_id 483 | | Rst_stream { stream_id; _ } -> stream_id 484 | | Settings { stream_id; _ } -> stream_id 485 | | Push_promise _ -> 0x0 486 | | Ping _ -> 0x0 487 | | Go_away _ -> 0x0 488 | | Window_update { stream_id; _ } -> stream_id 489 | | Continuation { stream_id; _ } -> stream_id 490 | | Unknown { stream_id; _ } -> stream_id 491 | 492 | let data t = Data t 493 | let headers t = Headers t 494 | let priority t = Priority t 495 | let rst_stream t = Rst_stream t 496 | let settings t = Settings t 497 | let push_promise t = Push_promise t 498 | let ping t = Ping t 499 | let go_away t = Go_away t 500 | let window_update t = Window_update t 501 | let continuation t = Continuation t 502 | let empty_settings = settings Settings.empty 503 | 504 | let make ~type_ ~flags ~stream_id ~payload = 505 | Logger.trace (fun f -> f "Frame type=%d" type_); 506 | let result = 507 | match type_ with 508 | | 0x0 -> Data.make ~flags ~stream_id ~payload |> Result.map data 509 | | 0x1 -> Headers.make ~flags ~stream_id ~payload |> Result.map headers 510 | | 0x2 -> Priority.make ~flags ~stream_id ~payload |> Result.map priority 511 | | 0x3 -> Rst_stream.make ~flags ~stream_id ~payload |> Result.map rst_stream 512 | | 0x4 -> Settings.make ~flags ~stream_id ~payload |> Result.map settings 513 | | 0x5 -> 514 | Push_promise.make ~flags ~stream_id ~payload |> Result.map push_promise 515 | | 0x6 -> Ping.make ~flags ~stream_id ~payload |> Result.map ping 516 | | 0x7 -> Go_away.make ~flags ~stream_id ~payload |> Result.map go_away 517 | | 0x8 -> 518 | Window_update.make ~flags ~stream_id ~payload 519 | |> Result.map window_update 520 | | 0x9 -> 521 | Continuation.make ~flags ~stream_id ~payload |> Result.map continuation 522 | | _ -> 523 | let payload = 524 | Bytestring.of_string (Bitstring.string_of_bitstring payload) 525 | in 526 | Ok (Unknown { stream_id; flags; payload }) 527 | in 528 | match result with Ok t -> `ok t | Error e -> `error e 529 | 530 | let deserialize ~max_frame_size data = 531 | let data = Bitstring.bitstring_of_string data in 532 | match%bitstring data with 533 | | {| length : 24 ; 534 | type_ : 8 ; 535 | flags : 8 ; 536 | _rsv : 1 ; 537 | stream_id : 31 ; 538 | payload : (length * 8) : bitstring ; 539 | rest : -1 : string 540 | |} 541 | when length <= max_frame_size -> 542 | Some (make ~type_ ~flags ~stream_id ~payload, rest) 543 | | {| data : -1 : string |} -> Some (`more (Bytestring.of_string data), "") 544 | 545 | let parts frame = 546 | match frame with 547 | | Data t -> Data.parts t 548 | | Headers t -> Headers.parts t 549 | | Priority t -> Priority.parts t 550 | | Rst_stream t -> Rst_stream.parts t 551 | | Settings t -> Settings.parts t 552 | | Push_promise () -> [] 553 | | Ping t -> Ping.parts t 554 | | Go_away t -> Go_away.parts t 555 | | Window_update t -> Window_update.parts t 556 | | Continuation t -> Continuation.parts t 557 | | Unknown { stream_id; flags; payload } -> 558 | let payload = 559 | Bytestring.to_string payload |> Bitstring.bitstring_of_string 560 | in 561 | [ (0x3, stream_id, flags, payload) ] 562 | 563 | let serialize frame = 564 | let frames = 565 | parts frame 566 | |> List.map (fun (type_, stream_id, flags, payload) -> 567 | let length = Bitstring.bitstring_length payload in 568 | let%bitstring header = 569 | {| length : 24; 570 | type_ : 8; 571 | flags : 8; 572 | 0 : 1; 573 | stream_id : 31 574 | |} 575 | in 576 | Bitstring.concat [ header; payload ]) 577 | |> Bitstring.concat 578 | in 579 | let frames = Bitstring.string_of_bitstring frames in 580 | Logger.trace (fun f -> f "htt2.frame.serialize %S" frames); 581 | Bytestring.of_string frames 582 | -------------------------------------------------------------------------------- /nomad/handler.ml: -------------------------------------------------------------------------------- 1 | type response = 2 | | Close of Atacama.Connection.t 3 | | Upgrade : 4 | [ `websocket of Trail.Sock.upgrade_opts * Trail.Sock.t | `h2c ] 5 | -> response 6 | 7 | type t = Atacama.Connection.t -> Trail.Request.t -> response 8 | -------------------------------------------------------------------------------- /nomad/http1.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | open Atacama.Handler 3 | include Atacama.Handler.Default 4 | 5 | let ( let* ) = Result.bind 6 | 7 | open Logger.Make (struct 8 | let namespace = [ "nomad"; "http1" ] 9 | end) 10 | 11 | module Parser = struct 12 | exception Bad_request 13 | exception Need_more_data 14 | exception Uri_too_long 15 | exception Header_fields_too_large 16 | 17 | open Logger.Make (struct 18 | let namespace = [ "nomad"; "http1"; "parser" ] 19 | end) 20 | 21 | let until ?(max_len = -1) char data = 22 | let rec go char data len = 23 | let left = Bitstring.bitstring_length data in 24 | if left = 0 then raise_notrace Need_more_data 25 | else if max_len > 0 && len > max_len then raise_notrace Uri_too_long 26 | else 27 | match%bitstring data with 28 | | {| curr : (String.length char * 8) : string; rest : -1 : bitstring |} 29 | -> 30 | if String.equal curr char then (len, rest) 31 | else 32 | let rest = 33 | Bitstring.( 34 | concat 35 | [ 36 | bitstring_of_string 37 | (String.sub curr 1 (String.length curr - 1)); 38 | rest; 39 | ]) 40 | in 41 | go char rest (len + 8) 42 | | {| _ |} -> raise_notrace Need_more_data 43 | in 44 | let len, rest = go char data 0 in 45 | let captured = Bitstring.subbitstring data 0 len in 46 | (captured, rest) 47 | 48 | let rec parse ~(config : Config.t) data = 49 | let str = Bytestring.to_string data in 50 | let bit = str |> Bitstring.bitstring_of_string in 51 | match do_parse ~config bit with 52 | | exception Bad_request -> `bad_request 53 | | exception Uri_too_long -> `uri_too_long 54 | | exception Header_fields_too_large -> `header_fields_too_large 55 | | exception Need_more_data -> `more data 56 | | req -> 57 | trace (fun f -> 58 | f "parsed_request: %a -> preread_body=%d" Trail.Request.pp req 59 | (Bytestring.length req.buffer)); 60 | `ok req 61 | 62 | and do_parse ~config data = 63 | let meth, rest = parse_method config.max_line_request_length data in 64 | let path, rest = parse_path config.max_line_request_length rest in 65 | let version, rest = parse_protocol_version rest in 66 | let headers, rest = 67 | parse_headers config.max_header_count config.max_header_length rest 68 | in 69 | trace (fun f -> f "creating request"); 70 | let body = Bitstring.string_of_bitstring rest in 71 | let body = Bytestring.of_string body in 72 | Trail.Request.make ~body ~meth ~version ~headers path 73 | 74 | and parse_method max_len data = 75 | let meth, rest = until ~max_len " " data in 76 | let meth = Bitstring.string_of_bitstring meth in 77 | (Http.Method.of_string meth, rest) 78 | 79 | and parse_path max_len data = 80 | let path, rest = until ~max_len " " data in 81 | (Bitstring.string_of_bitstring path, rest) 82 | 83 | and parse_protocol_version data = 84 | match%bitstring data with 85 | | {| "HTTP/1.0" : 8*8 : string; rest : -1 : bitstring |} -> (`HTTP_1_0, rest) 86 | | {| "HTTP/1.1" : 8*8 : string; rest : -1 : bitstring |} -> (`HTTP_1_1, rest) 87 | | {| _ |} -> raise_notrace Bad_request 88 | 89 | and parse_headers max_count max_length data = 90 | match%bitstring data with 91 | (* we found the end of the headers *) 92 | | {| "\r\n\r\n" : 4 * 8 : string ; rest : -1 : bitstring |} -> 93 | trace (fun f -> f "found body"); 94 | ([], rest) 95 | (* we found a beginning to the headers, but nothing more, so we need more data *) 96 | | {| "\r\n" : 2 * 8 : string ; rest : -1 : bitstring|} 97 | when Bitstring.bitstring_length rest = 0 -> 98 | trace (fun f -> f "need more data"); 99 | raise_notrace Need_more_data 100 | (* we found a beginning to the headers, so we can try to parse them *) 101 | | {| "\r\n" : 2 * 8 : string ; rest : -1 : bitstring |} -> 102 | trace (fun f -> f "parsing headers"); 103 | do_parse_headers max_count max_length rest [] 104 | (* anything else is probably a bad request *) 105 | | {| _ |} -> raise_notrace Bad_request 106 | 107 | and header_above_limit limit acc = 108 | (* NOTE(@leostera:) we add 4 here since we want to consider the `: ` 109 | between the header name and the value and the `\r\n` at the end *) 110 | acc 111 | |> List.exists (fun (k, v) -> 4 + String.length k + String.length v > limit) 112 | 113 | and do_parse_headers max_count max_length data acc = 114 | if List.length acc > max_count || header_above_limit max_length acc then 115 | raise_notrace Header_fields_too_large 116 | else 117 | (* we'll try to find the end of a header name *) 118 | match until ":" data with 119 | | exception Need_more_data -> ( 120 | (* if we didn't find a header name.. *) 121 | match%bitstring data with 122 | (* ...and the next thing we see is the end of the headers, we're good *) 123 | | {| "\r\n\r\n" : 4 * 8 : string ; rest : -1 : bitstring |} 124 | when List.length acc = 0 -> 125 | trace (fun f -> f "end of headers! (no headers)"); 126 | (acc, rest) 127 | (* ...or if the next thing we see a half end, and we had some headers, we're good*) 128 | | {| "\r\n" : 2 * 8 : string ; rest : -1 : bitstring |} 129 | when List.length acc > 0 -> 130 | trace (fun f -> 131 | f "end of headers! (header_count=%d)" (List.length acc)); 132 | (acc, rest) 133 | (* ...anything else is probably a bad request *) 134 | | {| _ |} -> raise_notrace Bad_request) 135 | | h, data -> 136 | trace (fun f -> f "found header %s" (Bitstring.string_of_bitstring h)); 137 | let header, rest = parse_header h data in 138 | let acc = header :: acc in 139 | do_parse_headers max_count max_length rest acc 140 | 141 | and parse_header h rest = 142 | let clean s = String.(s |> Bitstring.string_of_bitstring |> trim) in 143 | let v, rest = until "\r\n" rest in 144 | let h = clean h in 145 | let v = clean v in 146 | ((h, v), rest) 147 | end 148 | 149 | type state = { 150 | sniffed_data : Bytestring.t; 151 | is_keep_alive : bool; 152 | handler : Handler.t; 153 | are_we_tls : bool; 154 | config : Config.t; 155 | max_requests : int; 156 | requests_processed : int; 157 | } 158 | 159 | type error = [ `Excess_body_read | IO.io_error ] 160 | 161 | exception Bad_port 162 | exception Path_missing_leading_slash 163 | exception Uri_too_long 164 | exception Bad_request 165 | 166 | let pp_err fmt t = 167 | match t with 168 | | `Excess_body_read -> Format.fprintf fmt "Excess body read" 169 | | #IO.io_error as io_err -> IO.pp_err fmt io_err 170 | 171 | let make ~are_we_tls ~sniffed_data ~handler ~config () = 172 | { 173 | sniffed_data; 174 | handler; 175 | are_we_tls; 176 | config; 177 | is_keep_alive = false; 178 | max_requests = 0; 179 | requests_processed = 0; 180 | } 181 | 182 | let handle_connection _conn state = 183 | trace (fun f -> f "switched to http1"); 184 | Continue state 185 | 186 | module StringSet = Set.Make (String) 187 | 188 | let make_uri state (req : Trail.Request.t) = 189 | let h = Http.Header.get req.headers in 190 | 191 | let scheme, port = 192 | if state.are_we_tls then ("https", 443) else ("http", 80) 193 | in 194 | 195 | let uri = 196 | match (h "host", Uri.host req.uri) with 197 | | Some host, None -> Uri.of_string (scheme ^ "://" ^ host) 198 | | _ -> req.uri 199 | in 200 | let uri = 201 | match Uri.port uri with 202 | | Some _port -> uri 203 | | None -> Uri.with_port uri (Some port) 204 | in 205 | 206 | if Uri.port uri |> Option.value ~default:0 < 0 then raise_notrace Bad_port; 207 | 208 | let path = Uri.path req.uri in 209 | let query = Uri.query req.uri in 210 | 211 | (* If this is an OPTIONS request it may come with a path to indicate that 212 | these are global options for requests. *) 213 | if req.meth = `OPTIONS && path = "*" then () 214 | (* otherwise, we expect all requests to come with a leading slash. *) 215 | else if not (String.starts_with ~prefix:"/" path) then 216 | raise_notrace Path_missing_leading_slash; 217 | 218 | let uri = Uri.with_path uri path in 219 | let uri = Uri.with_query uri query in 220 | trace (fun f -> f "parse uri: %a" Uri.pp uri); 221 | uri 222 | 223 | let send_close res ?(req = Trail.Request.make "noop") conn state = 224 | let _ = Adapter.send conn req res in 225 | Close state 226 | 227 | let should_keep_alive (req : Trail.Request.t) = 228 | match (req.version, Http.Header.get req.headers "connection") with 229 | | _, Some "close" -> false 230 | | _, Some "keep-alive" -> true 231 | | `HTTP_1_1, _ -> true 232 | | _, _ -> false 233 | 234 | let internal_server_error = send_close Trail.Response.(internal_server_error ()) 235 | let bad_request = send_close Trail.Response.(bad_request ()) 236 | let uri_too_long = send_close Trail.Response.(request_uri_too_long ()) 237 | 238 | let header_fields_too_large = 239 | send_close Trail.Response.(request_header_fields_too_large ()) 240 | 241 | let rec ensure_body_read conn req = 242 | match Adapter.read_body conn req with 243 | | Ok (_, _) -> Result.Ok () 244 | | More (req, _) -> ensure_body_read conn req 245 | | Error (_, reason) -> Error reason 246 | 247 | let maybe_keep_alive state conn (req : Trail.Request.t) = 248 | let requests_processed = state.requests_processed + 1 in 249 | let under_limit = 250 | state.max_requests == 0 || requests_processed < state.max_requests 251 | in 252 | trace (fun f -> 253 | f "requests_processed %d | under limit? %b | is_keep_alive? %b" 254 | requests_processed under_limit state.is_keep_alive); 255 | if under_limit && state.is_keep_alive then 256 | match ensure_body_read conn req with 257 | | Ok () -> Continue { state with sniffed_data = {%b||}; requests_processed } 258 | | Error `Closed -> Close state 259 | | Error reason -> Error (state, reason) 260 | else Close state 261 | 262 | let handle_request state conn req = 263 | trace (fun f -> f "handle_request: %a" Trail.Request.pp req); 264 | match state.handler conn req with 265 | | Handler.Close _conn -> maybe_keep_alive state conn req 266 | | Handler.Upgrade (`websocket (upgrade_opts, handler)) -> 267 | let state = Ws.make ~upgrade_opts ~handler ~req ~conn () in 268 | let state = Ws.handshake req conn state in 269 | Switch (H { handler = (module Ws); state }) 270 | | Handler.Upgrade `h2c -> 271 | trace (fun f -> f "upgrading to h2c"); 272 | let state = Http2.make ~handler:state.handler ~conn () in 273 | let state = Http2.handshake req conn state in 274 | Switch (H { handler = (module Http2); state }) 275 | 276 | let run_handler state conn req = 277 | trace (fun f -> f "run_handler: %a" Trail.Request.pp req); 278 | match 279 | let uri = make_uri state req in 280 | let req = { req with uri } in 281 | let host = 282 | match (Http.Header.get req.headers "host", Uri.host uri) with 283 | | Some host, _ -> Some host 284 | | _, Some "" -> None 285 | | _, Some host -> Some host 286 | | _ -> None 287 | in 288 | let _content_length = Trail.Request.content_length req in 289 | let is_keep_alive = should_keep_alive req in 290 | trace (fun f -> f "connection is keep alive? %b" is_keep_alive); 291 | let state = { state with is_keep_alive } in 292 | (state, req.version, host) 293 | with 294 | | state, `HTTP_1_1, Some _host -> handle_request state conn req 295 | | state, `HTTP_1_0, _host -> handle_request state conn req 296 | | state, _http, _host -> bad_request ~req conn state 297 | 298 | let handle_data data conn state = 299 | trace (fun f -> f "handling data: %a" Pid.pp (self ())); 300 | let data, state = 301 | let data = Bytestring.join state.sniffed_data data in 302 | (data, { state with sniffed_data = {%b||} }) 303 | in 304 | trace (fun f -> f "handling data: %d bytes" (Bytestring.length data)); 305 | 306 | match 307 | match Parser.parse ~config:state.config data with 308 | | `ok req -> run_handler state conn req 309 | | `bad_request -> bad_request conn state 310 | | `uri_too_long -> uri_too_long conn state 311 | | `header_fields_too_large -> header_fields_too_large conn state 312 | | `more data -> 313 | trace (fun f -> f "need more data: %d bytes" (Bytestring.length data)); 314 | Continue { state with sniffed_data = data } 315 | with 316 | | exception 317 | (( Bad_request | Bad_port | Path_missing_leading_slash 318 | | Trail.Request.Invalid_content_header ) as exn) -> 319 | error (fun f -> 320 | f "bad_request: %s\n%s" (Printexc.to_string exn) 321 | (Printexc.get_backtrace ())); 322 | bad_request conn state 323 | | exception exn -> 324 | error (fun f -> 325 | f "internal_server_error: %s\n%s" (Printexc.to_string exn) 326 | (Printexc.get_backtrace ())); 327 | internal_server_error conn state 328 | | continue -> continue 329 | -------------------------------------------------------------------------------- /nomad/http2.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | open Atacama.Handler 3 | include Atacama.Handler.Default 4 | 5 | module Logger = Logger.Make (struct 6 | let namespace = [ "nomad"; "http2" ] 7 | end) 8 | 9 | let ( let* ) = Result.bind 10 | 11 | module H2_stream = struct 12 | type Message.t += Frame of Frame.t 13 | 14 | let rec loop conn = 15 | match receive_any () with 16 | | Frame frame -> 17 | Logger.debug (fun f -> f "frame: %a" Frame.pp frame); 18 | loop conn 19 | | _ -> loop conn 20 | 21 | let init stream_id conn = 22 | Logger.debug (fun f -> 23 | f "Stream %a stream_id=%d initialized" Pid.pp (self ()) stream_id); 24 | loop conn 25 | 26 | let start_link ~stream_id ~conn = 27 | let pid = spawn_link (fun () -> init stream_id conn) in 28 | Result.Ok pid 29 | 30 | let send_frame ~frame pid = send pid (Frame frame) 31 | end 32 | 33 | type settings = { 34 | header_table_size : int; 35 | initial_window_size : int; 36 | max_frame_size : int; 37 | } 38 | 39 | let default_settings = 40 | { 41 | header_table_size = 4_096; 42 | initial_window_size = 65_535; 43 | max_frame_size = 16_384; 44 | } 45 | 46 | type state = { 47 | request : Http.Request.t; 48 | handler : Handler.t; 49 | buffer : Bytestring.t; 50 | conn : Atacama.Connection.t; 51 | settings : settings; 52 | streams : (Frame.stream_id, Pid.t) Hashtbl.t; 53 | } 54 | 55 | type error = 56 | [ `protocol_error of 57 | [ `continuation_frame_with_zero_stream_id 58 | | `data_frame_with_invalid_padding_length 59 | | `data_frame_with_zero_stream_id 60 | | `headers_frame_with_invalid_padding_length 61 | | `headers_frame_with_zero_stream_id 62 | | `invalid_payload_size_in_goaway_frame 63 | | `invalid_payload_size_in_ping_frame 64 | | `invalid_payload_size_in_priority_frame 65 | | `invalid_payload_size_in_rst_stream 66 | | `invalid_settings_frame_with_stream_id of Frame.stream_id 67 | | `invalid_stream_id_in_goaway_frame 68 | | `invalid_stream_id_in_ping_frame 69 | | `invalid_window_update_frame 70 | | `invalid_window_update_size_increment 71 | | `priority_frame_with_zero_stream_id 72 | | `push_promise_frame_received 73 | | `rst_stream_frame_with_zero_stream_id ] 74 | | `settings_error of 75 | [ `frame_size_too_large of int 76 | | `frame_size_too_small of int 77 | | `invalid_enable_push_value 78 | | `window_size_too_large of int ] 79 | | `could_not_initialize_connection ] 80 | 81 | let err_to_str (err : error) = 82 | match err with 83 | | `could_not_initialize_connection -> "Could not initialize HTTP/2 connection" 84 | | `protocol_error (`invalid_settings_frame_with_stream_id sid) -> 85 | Format.sprintf "Protocol error: invalid SETTINGS frame with stream_id=%d" 86 | sid 87 | | `protocol_error `continuation_frame_with_zero_stream_id -> 88 | "Protocol error: invalid CONTINUATION frame with stream_id=0" 89 | | `protocol_error `headers_frame_with_zero_stream_id -> 90 | "Protocol error: invalid HEADERS frame with stream_id=0" 91 | | `protocol_error `data_frame_with_zero_stream_id -> 92 | "Protocol error: invalid DATA frame with stream_id=0" 93 | | `protocol_error `data_frame_with_invalid_padding_length -> 94 | "Protocol error: DATA frame with invalid padding length" 95 | | `settings_error (`frame_size_too_small size) -> 96 | Format.sprintf "SETTINGS error: max frame size of %d is too small " size 97 | | `settings_error (`frame_size_too_large size) -> 98 | Format.sprintf "SETTINGS error: max frame size of %d is too large " size 99 | | `settings_error (`window_size_too_large size) -> 100 | Format.sprintf "SETTINGS error: initial window size of %d is too large " 101 | size 102 | | `settings_error `invalid_enable_push_value -> 103 | "Protocol error: invalid `enable_push` frame settings value" 104 | | err -> Marshal.to_string err [] 105 | 106 | let pp_err fmt err = Format.fprintf fmt "%s" (err_to_str err) 107 | 108 | let make ?(settings = default_settings) ~handler ~conn () = 109 | { 110 | request = Http.Request.make ""; 111 | handler; 112 | buffer = Bytestring.empty; 113 | conn; 114 | settings; 115 | streams = Hashtbl.create 128; 116 | } 117 | 118 | let handshake req conn state = 119 | let res = 120 | Trail.Response.( 121 | make `Switching_protocols 122 | ~headers:[ ("upgrade", "h2c"); ("connection", "Upgrade") ] 123 | ()) 124 | in 125 | Adapter.send conn req res; 126 | state 127 | 128 | let handle_connection conn state = 129 | Logger.debug (fun f -> f "switched to http2"); 130 | let frame = Frame.serialize Frame.empty_settings in 131 | match Atacama.Connection.send conn frame with 132 | | Ok () -> 133 | Logger.debug (fun f -> f "sent %d bytes" (Bytestring.length frame)); 134 | Continue state 135 | | _ -> Error (state, `could_not_initialize_connection) 136 | 137 | let handle_frame frame conn state = 138 | let stream_id = Frame.stream_id frame in 139 | let stream_pid = 140 | match Hashtbl.find_opt state.streams stream_id with 141 | | Some pid -> pid 142 | | None -> 143 | (* FIXME(@leostera): T_T *) 144 | let[@warning "-8"] Result.(Ok pid) = 145 | H2_stream.start_link ~stream_id ~conn 146 | in 147 | Hashtbl.add state.streams stream_id pid; 148 | pid 149 | in 150 | H2_stream.send_frame stream_pid ~frame; 151 | `continue (Continue state) 152 | 153 | let handle_data data conn state = 154 | let data = Bytestring.to_string state.buffer ^ Bytestring.to_string data in 155 | data 156 | |> Stream.unfold 157 | (Frame.deserialize ~max_frame_size:state.settings.max_frame_size) 158 | |> Stream.reduce_while (Continue state) @@ fun frame state -> 159 | match (frame, state) with 160 | | `ok frame, Continue state -> handle_frame frame conn state 161 | | `more buffer, Continue state -> `halt (Continue { state with buffer }) 162 | | `error reason, Continue state -> `halt (Error (state, reason)) 163 | | _, _ -> failwith "Unexpected_frame_parsing_error" 164 | -------------------------------------------------------------------------------- /nomad/negotiator.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | open Atacama.Handler 3 | 4 | open Logger.Make (struct 5 | let namespace = [ "nomad"; "negotiator" ] 6 | end) 7 | 8 | let ( let* ) = Result.bind 9 | 10 | let alpn_protocol conn = 11 | match Atacama.Connection.negotiated_protocol conn with 12 | | Some "http/1.1" -> `http1 13 | | Some "h2" -> `http2 14 | | _ -> `no_match 15 | 16 | let sniff_wire conn = 17 | let* data = Atacama.Connection.receive ~limit:24 conn in 18 | match%b data with 19 | | {| "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"::bytes, _rest::bytes |} -> 20 | Result.Ok `http2 21 | | {| data::bytes |} -> Ok (`no_match data) 22 | 23 | let negotiated_protocol ~enabled_protocols ~config conn handler = 24 | let enabled proto = List.mem proto enabled_protocols in 25 | let* wire = sniff_wire conn in 26 | let alpn = alpn_protocol conn in 27 | match (alpn, wire) with 28 | | (`http2 | `no_match), `http2 when enabled `http2 -> 29 | debug (fun f -> f " http2 detected! "); 30 | let state = Protocol.Http2.make ~handler ~conn () in 31 | Ok (H { handler = (module Protocol.Http2); state }) 32 | | (`http1 | `no_match), `no_match sniffed_data when enabled `http1 -> 33 | let are_we_tls = alpn = `http1 in 34 | debug (fun f -> 35 | f " http1 detected! (sniffed_data = %S) " 36 | (Bytestring.to_string sniffed_data)); 37 | let state = 38 | Protocol.Http1.make ~config ~are_we_tls ~sniffed_data ~handler () 39 | in 40 | Ok (H { handler = (module Protocol.Http1); state }) 41 | | _ -> Error `No_protocol_matched 42 | -------------------------------------------------------------------------------- /nomad/nomad.ml: -------------------------------------------------------------------------------- 1 | module Adapter = Adapter 2 | module Config = Config 3 | module Handler = Handler 4 | module Protocol = Protocol 5 | module Request = Request 6 | 7 | let start_link ?acceptors ?transport ?config ~port ~handler () = 8 | Atacama.start_link ~port ?acceptors ?transport 9 | (module Connection_handler) 10 | (Connection_handler.make ~handler ?config ()) 11 | 12 | let trail tr conn req = 13 | match Trail.handler (module Adapter) tr conn req with 14 | | `upgrade upgrade -> Handler.Upgrade upgrade 15 | | `close -> Handler.Close conn 16 | -------------------------------------------------------------------------------- /nomad/nomad.mli: -------------------------------------------------------------------------------- 1 | open Trail 2 | 3 | module Config : sig 4 | type t = { 5 | max_line_request_length : int; 6 | max_header_count : int; 7 | max_header_length : int; 8 | request_receive_timeout : int64; 9 | } 10 | 11 | val make : 12 | ?max_line_request_length:int -> 13 | ?max_header_count:int -> 14 | ?max_header_length:int -> 15 | ?request_receive_timeout:int64 -> 16 | unit -> 17 | t 18 | end 19 | 20 | module Handler : sig 21 | type response = 22 | | Close of Atacama.Connection.t 23 | | Upgrade : [ `websocket of Sock.upgrade_opts * Sock.t | `h2c ] -> response 24 | 25 | type t = Atacama.Connection.t -> Trail.Request.t -> response 26 | end 27 | 28 | val start_link : 29 | ?acceptors:int -> 30 | ?transport:Atacama.Transport.t -> 31 | ?config:Config.t -> 32 | port:int -> 33 | handler:Handler.t -> 34 | unit -> 35 | (Riot.Pid.t, [> `Supervisor_error ]) result 36 | 37 | val trail : 38 | Trail.t -> Atacama.Connection.t -> Trail.Request.t -> Handler.response 39 | -------------------------------------------------------------------------------- /nomad/protocol.ml: -------------------------------------------------------------------------------- 1 | module type Intf = sig 2 | include Atacama.Handler.Intf 3 | end 4 | 5 | module Http1 = Http1 6 | module Http2 = Http2 7 | module Ws = Ws 8 | -------------------------------------------------------------------------------- /nomad/request.ml: -------------------------------------------------------------------------------- 1 | (* 2 | type t = { 3 | headers : Http.Header.t; 4 | meth : Http.Method.t; 5 | uri : Uri.t; 6 | version : Http.Version.t; 7 | encoding : Http.Transfer.encoding; 8 | } 9 | 10 | let pp fmt ({ headers; meth; uri; version; _ } : t) = 11 | let req = Http.Request.make ~meth ~headers ~version (Uri.to_string uri) in 12 | Http.Request.pp fmt req 13 | 14 | *) 15 | -------------------------------------------------------------------------------- /nomad/ws.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | open Atacama.Handler 3 | include Atacama.Handler.Default 4 | 5 | open Riot.Logger.Make (struct 6 | let namespace = [ "nomad"; "ws" ] 7 | end) 8 | 9 | let ( let* ) = Result.bind 10 | 11 | type state = { 12 | upgrade_opts : Trail.Sock.upgrade_opts; 13 | handler : Trail.Sock.t; 14 | req : Trail.Request.t; 15 | buffer : Bytestring.t; 16 | conn : Atacama.Connection.t; 17 | } 18 | 19 | type error = [ `Unknown_opcode of int ] 20 | 21 | let pp_err _fmt _ = () 22 | 23 | let make ~upgrade_opts ~handler ~req ~conn () = 24 | { upgrade_opts; handler; req; buffer = Bytestring.empty; conn } 25 | 26 | let handshake (req : Trail.Request.t) conn state = 27 | let[@warning "-8"] (Some client_key) = 28 | Http.Header.get req.headers "sec-websocket-key" 29 | in 30 | let concatenated_key = client_key ^ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" in 31 | let hashed_key = 32 | Digestif.SHA1.(digest_string concatenated_key |> to_raw_string) 33 | in 34 | let server_key = Base64.encode_string hashed_key in 35 | 36 | let res = 37 | Trail.Response.( 38 | make `Switching_protocols 39 | ~headers: 40 | [ 41 | ("upgrade", "websocket"); 42 | ("connection", "Upgrade"); 43 | ("sec-websocket-accept", server_key); 44 | ] 45 | ()) 46 | in 47 | 48 | Adapter.send conn req res; 49 | state 50 | 51 | let handle_connection conn state = 52 | debug (fun f -> f "switched to ws"); 53 | info (fun f -> f "Request: %a" Trail.Request.pp state.req); 54 | match Trail.Sock.init state.handler conn with 55 | | `continue (conn, handler) -> Continue { state with conn; handler } 56 | | `error (conn, reason) -> Error ({ state with conn }, reason) 57 | 58 | let rec send_frames state conn frames return = 59 | match frames with 60 | | [] -> return 61 | | frame :: frames -> ( 62 | let data = Trail.Frame.Response.serialize frame in 63 | match Atacama.Connection.send conn data with 64 | | Ok _n -> send_frames state conn frames return 65 | | Error `Eof -> 66 | error (fun f -> f "ws.error: end of file"); 67 | `halt (Close state) 68 | | Error ((`Closed | `Timeout | `Process_down | `Unix_error _ | _) as err) 69 | -> 70 | error (fun f -> f "ws.error: %a" IO.pp_err err); 71 | `halt (Close state)) 72 | 73 | let handle_data data conn state = 74 | let data = Bytestring.(to_string (state.buffer ^ data)) in 75 | trace (fun f -> f "handling data: %d bytes" (String.length data)); 76 | Stream.unfold Trail.Frame.Request.deserialize data 77 | |> Stream.reduce_while (Continue state) @@ fun frame state -> 78 | match (frame, state) with 79 | | `ok frame, Continue state -> ( 80 | trace (fun f -> f "handling frame: %a" Trail.Frame.pp frame); 81 | match[@warning "-8"] 82 | Trail.Sock.handle_frame state.handler frame conn 83 | with 84 | | `push (frames, handler) -> 85 | let state = { state with handler } in 86 | send_frames state conn frames (`continue (Continue state)) 87 | | `continue (conn, handler) -> 88 | `continue (Continue { state with conn; handler }) 89 | | `close conn -> `halt (Close { state with conn }) 90 | | `error (conn, reason) -> `halt (Error ({ state with conn }, reason))) 91 | | `more buffer, Continue state -> `halt (Continue { state with buffer }) 92 | | `error reason, Continue state -> `halt (Error (state, reason)) 93 | | _, _ -> failwith "Unexpected_frame_parsing_error" 94 | 95 | let handle_message msg conn state = 96 | match Trail.Sock.handle_message state.handler msg conn with 97 | | `continue (conn, handler) -> Continue { state with conn; handler } 98 | | `error (conn, reason) -> Error ({ state with conn }, reason) 99 | | `push (frames, handler) -> ( 100 | let state = { state with handler } in 101 | match send_frames state conn frames (`continue (Continue state)) with 102 | | `continue cont -> cont 103 | | `halt res -> res) 104 | -------------------------------------------------------------------------------- /test/autobahn/fuzzingclient.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "ws://127.0.0.1:2113", 3 | "outdir": "./reports/clients", 4 | "cases": ["*"], 5 | "exclude-cases": [], 6 | "exclude-agent-cases": {} 7 | } 8 | -------------------------------------------------------------------------------- /test/autobahn/fuzzingserver.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "ws://127.0.0.1:2113", 3 | "outdir": "./reports/clients", 4 | "cases": ["*"], 5 | "exclude-cases": [], 6 | "exclude-agent-cases": {} 7 | } 8 | -------------------------------------------------------------------------------- /test/autobahn/nomad.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "ws://127.0.0.1:2112", 3 | "outdir": "./reports/clients", 4 | "cases": ["*"], 5 | "exclude-cases": [], 6 | "exclude-agent-cases": {} 7 | } 8 | -------------------------------------------------------------------------------- /test/autobahn/run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run -it --rm \ 4 | -v "${PWD}/test/autobahn/fuzzingclient.json:/fuzzingclient.json" \ 5 | -v "${PWD}/_build/reports:/reports" \ 6 | -p 2113:2113 \ 7 | --name nomad \ 8 | crossbario/autobahn-testsuite \ 9 | wstest --mode fuzzingclient \ 10 | -w ws://host.docker.internal:2112 11 | -------------------------------------------------------------------------------- /test/autobahn/server.ml: -------------------------------------------------------------------------------- 1 | open Riot 2 | 3 | module Echo_server = struct 4 | type args = unit 5 | type state = int 6 | 7 | let init conn _args = `continue (conn, 0) 8 | 9 | let handle_frame frame _conn _state = 10 | Logger.info (fun f -> f "handling frame: %a" Trail.Frame.pp frame); 11 | `push [ frame ] 12 | end 13 | 14 | module Test : Application.Intf = struct 15 | let name = "test" 16 | 17 | let start () = 18 | Logger.set_log_level (Some Debug); 19 | sleep 0.1; 20 | Logger.info (fun f -> f "starting nomad server"); 21 | 22 | let ws_echo (conn : Trail.Conn.t) = 23 | let handler = Trail.Sock.make (module Echo_server) () in 24 | let upgrade_opts = Trail.Sock.{ do_upgrade = true } in 25 | conn |> Trail.Conn.upgrade (`websocket (upgrade_opts, handler)) 26 | in 27 | 28 | let handler = Nomad.trail [ Trail.logger ~level:Debug (); ws_echo ] in 29 | 30 | Nomad.start_link ~port:2112 ~handler () 31 | end 32 | 33 | let () = Riot.start ~apps:[ (module Logger); (module Test) ] () 34 | -------------------------------------------------------------------------------- /test/bandit/.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-dir=is:_build 2 | --ignore-dir=is:deps 3 | --ignore-dir=is:doc 4 | --ignore-dir=is:priv/plts 5 | --ignore-dir=is:.elixir_ls 6 | -------------------------------------------------------------------------------- /test/bandit/.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | # 3 | configs: [ 4 | %{ 5 | name: "default", 6 | strict: true, 7 | checks: %{ 8 | enabled: [ 9 | # 10 | ## Consistency Checks 11 | # 12 | {Credo.Check.Consistency.ExceptionNames, []}, 13 | {Credo.Check.Consistency.LineEndings, []}, 14 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 15 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 16 | {Credo.Check.Consistency.SpaceInParentheses, []}, 17 | {Credo.Check.Consistency.TabsOrSpaces, []}, 18 | 19 | # 20 | ## Design Checks 21 | # 22 | {Credo.Check.Design.AliasUsage, false}, 23 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 24 | {Credo.Check.Design.TagFIXME, []}, 25 | 26 | # 27 | ## Readability Checks 28 | # 29 | {Credo.Check.Readability.AliasOrder, []}, 30 | {Credo.Check.Readability.FunctionNames, []}, 31 | {Credo.Check.Readability.LargeNumbers, []}, 32 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 33 | {Credo.Check.Readability.ModuleAttributeNames, []}, 34 | {Credo.Check.Readability.ModuleDoc, []}, 35 | {Credo.Check.Readability.ModuleNames, []}, 36 | {Credo.Check.Readability.ParenthesesInCondition, []}, 37 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 38 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 39 | {Credo.Check.Readability.PredicateFunctionNames, []}, 40 | {Credo.Check.Readability.PreferImplicitTry, []}, 41 | {Credo.Check.Readability.RedundantBlankLines, []}, 42 | {Credo.Check.Readability.Semicolons, []}, 43 | {Credo.Check.Readability.SpaceAfterCommas, []}, 44 | {Credo.Check.Readability.StringSigils, []}, 45 | {Credo.Check.Readability.TrailingBlankLine, []}, 46 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 47 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 48 | {Credo.Check.Readability.VariableNames, []}, 49 | {Credo.Check.Readability.WithSingleClause, []}, 50 | 51 | # 52 | ## Refactoring Opportunities 53 | # 54 | {Credo.Check.Refactor.Apply, []}, 55 | {Credo.Check.Refactor.CondStatements, []}, 56 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 15]}, 57 | {Credo.Check.Refactor.FunctionArity, []}, 58 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 59 | {Credo.Check.Refactor.MatchInCondition, []}, 60 | {Credo.Check.Refactor.MapJoin, []}, 61 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 62 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 63 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 64 | {Credo.Check.Refactor.UnlessWithElse, []}, 65 | {Credo.Check.Refactor.WithClauses, []}, 66 | {Credo.Check.Refactor.FilterCount, []}, 67 | {Credo.Check.Refactor.FilterFilter, []}, 68 | {Credo.Check.Refactor.RejectReject, []}, 69 | 70 | # 71 | ## Warnings 72 | # 73 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 74 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 75 | {Credo.Check.Warning.Dbg, []}, 76 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 77 | {Credo.Check.Warning.IExPry, []}, 78 | {Credo.Check.Warning.IoInspect, []}, 79 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 80 | {Credo.Check.Warning.OperationOnSameValues, []}, 81 | {Credo.Check.Warning.OperationWithConstantResult, []}, 82 | {Credo.Check.Warning.RaiseInsideRescue, []}, 83 | {Credo.Check.Warning.SpecWithStruct, []}, 84 | {Credo.Check.Warning.WrongTestFileExtension, []}, 85 | {Credo.Check.Warning.UnusedEnumOperation, []}, 86 | {Credo.Check.Warning.UnusedFileOperation, []}, 87 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 88 | {Credo.Check.Warning.UnusedListOperation, []}, 89 | {Credo.Check.Warning.UnusedPathOperation, []}, 90 | {Credo.Check.Warning.UnusedRegexOperation, []}, 91 | {Credo.Check.Warning.UnusedStringOperation, []}, 92 | {Credo.Check.Warning.UnusedTupleOperation, []}, 93 | {Credo.Check.Warning.UnsafeExec, []} 94 | ], 95 | disabled: [ 96 | # 97 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 98 | # and be sure to use `mix credo --strict` to see low priority checks) 99 | # 100 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 101 | {Credo.Check.Consistency.UnusedVariableNames, []}, 102 | {Credo.Check.Design.DuplicatedCode, []}, 103 | {Credo.Check.Design.SkipTestWithoutComment, []}, 104 | {Credo.Check.Readability.AliasAs, []}, 105 | {Credo.Check.Readability.BlockPipe, []}, 106 | {Credo.Check.Readability.ImplTrue, []}, 107 | {Credo.Check.Readability.MultiAlias, []}, 108 | {Credo.Check.Readability.NestedFunctionCalls, []}, 109 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 110 | {Credo.Check.Readability.SeparateAliasRequire, []}, 111 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 112 | {Credo.Check.Readability.SinglePipe, []}, 113 | {Credo.Check.Readability.Specs, []}, 114 | {Credo.Check.Readability.StrictModuleLayout, []}, 115 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 116 | {Credo.Check.Readability.OnePipePerLine, []}, 117 | {Credo.Check.Refactor.ABCSize, []}, 118 | {Credo.Check.Refactor.AppendSingleItem, []}, 119 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 120 | {Credo.Check.Refactor.FilterReject, []}, 121 | {Credo.Check.Refactor.IoPuts, []}, 122 | {Credo.Check.Refactor.MapMap, []}, 123 | {Credo.Check.Refactor.ModuleDependencies, []}, 124 | {Credo.Check.Refactor.NegatedIsNil, []}, 125 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 126 | {Credo.Check.Refactor.PipeChainStart, []}, 127 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 128 | {Credo.Check.Refactor.RejectFilter, []}, 129 | {Credo.Check.Refactor.VariableRebinding, []}, 130 | {Credo.Check.Warning.LazyLogging, []}, 131 | {Credo.Check.Warning.LeakyEnvironment, []}, 132 | {Credo.Check.Warning.MapGetUnsafePass, []}, 133 | {Credo.Check.Warning.MixEnv, []}, 134 | {Credo.Check.Warning.UnsafeToAtom, []} 135 | 136 | # {Credo.Check.Refactor.MapInto, []}, 137 | ] 138 | } 139 | } 140 | ] 141 | } 142 | -------------------------------------------------------------------------------- /test/bandit/.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | {"lib/thousand_island/transports/ssl.ex", :unknown_type}, 3 | {"deps/thousand_island/lib/thousand_island/handler.ex", :unmatched_return}, 4 | # handle_connection/2 return type is extended and differs from behaviour it implements 5 | # it's not a problem because because InitialHandler is wrapped into DelegatingHandler, 6 | # but Dialyzer complains 7 | {"lib/bandit/initial_handler.ex", :callback_spec_type_mismatch}, 8 | # unmatched_return check doesn't have much sense in the test support code 9 | {"test/support/simple_h2_client.ex", :unmatched_return}, 10 | {"test/support/simple_http1_client.ex", :unmatched_return}, 11 | {"test/support/simple_websocket_client.ex", :unmatched_return}, 12 | {"test/support/telemetry_collector.ex", :unmatched_return}, 13 | ] 14 | -------------------------------------------------------------------------------- /test/bandit/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/bandit/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /test/bandit/.github/workflows/autobahn.yml: -------------------------------------------------------------------------------- 1 | name: Run autobahn 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | elixirs: 7 | type: string 8 | default: "[\"1.14.x\"]" 9 | erlangs: 10 | type: string 11 | default: "[\"25.x\"]" 12 | 13 | env: 14 | MIX_ENV: test 15 | 16 | jobs: 17 | autobahn: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | elixir: ${{ fromJSON(inputs.elixirs) }} 23 | otp: ${{ fromJSON(inputs.erlangs) }} 24 | case: 25 | - "1.*,2.*,3.*,4.*,5.*,6.*,7.*,8.*,10.*" 26 | - "9.1.*" 27 | - "9.2.*" 28 | - "9.3.*" 29 | - "12.1.*" 30 | - "12.2.*" 31 | - "12.3.*" 32 | - "12.4.*" 33 | - "12.5.*" 34 | - "13.1.*" 35 | - "13.2.*" 36 | - "13.3.*" 37 | - "13.4.*" 38 | - "13.5.*" 39 | - "13.6.*" 40 | - "13.7.*" 41 | exclude: 42 | - elixir: 1.12.x 43 | otp: 25.x 44 | - elixir: 1.14.x 45 | otp: 23.x 46 | env: 47 | AUTOBAHN_CASES: ${{ matrix.case }} 48 | steps: 49 | - name: Checkout code 50 | uses: actions/checkout@v4 51 | - name: Setup Elixir 52 | uses: erlef/setup-beam@v1 53 | with: 54 | elixir-version: ${{ matrix.elixir }} 55 | otp-version: ${{ matrix.otp }} 56 | - name: Disable compile warnings 57 | run: echo "::remove-matcher owner=elixir-mixCompileWarning::" 58 | - name: Retrieve mix dependencies cache 59 | uses: actions/cache@v3 60 | id: mix-cache 61 | with: 62 | path: | 63 | deps 64 | _build 65 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 66 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- 67 | - name: Install mix dependencies 68 | if: steps.mix-cache.outputs.cache-hit != 'true' 69 | run: | 70 | mix deps.get 71 | - name: Run Autobahn test 72 | run: mix test --only external_conformance test/bandit/websocket/autobahn_test.exs 73 | -------------------------------------------------------------------------------- /test/bandit/.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, reopened, synchronize, labeled ] 6 | 7 | env: 8 | MIX_ENV: test 9 | 10 | jobs: 11 | benchmark: 12 | if: contains(github.event.pull_request.labels.*.name, 'benchmark') 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Install nghttp2 16 | run: sudo apt-get install nghttp2 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | repository: mtrudel/benchmark 21 | - name: Setup Elixir 22 | uses: erlef/setup-beam@v1 23 | with: 24 | elixir-version: 1.14.x 25 | otp-version: 25.1 26 | - name: Install mix dependencies 27 | run: mix deps.get 28 | - name: Compile code 29 | run: mix compile 30 | - name: Run benchmark against base branch 31 | run: mix benchmark bandit@${{ github.event.pull_request.base.repo.clone_url }}@${{ github.event.pull_request.base.ref }} bandit@${{ github.event.pull_request.head.repo.clone_url }}@${{ github.event.pull_request.head.sha }} 32 | - name: Upload results file 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: http-benchmark.csv 36 | path: http-benchmark.csv 37 | - name: Record summary output 38 | id: summary 39 | run: cat http-summary.md >> $GITHUB_STEP_SUMMARY 40 | -------------------------------------------------------------------------------- /test/bandit/.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | uses: mtrudel/elixir-ci-actions/.github/workflows/test.yml@main 12 | lint: 13 | uses: mtrudel/elixir-ci-actions/.github/workflows/lint.yml@main 14 | h2spec: 15 | uses: ./.github/workflows/h2spec.yml 16 | autobahn: 17 | uses: ./.github/workflows/autobahn.yml 18 | -------------------------------------------------------------------------------- /test/bandit/.github/workflows/h2spec.yml: -------------------------------------------------------------------------------- 1 | name: Run h2spec 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | elixirs: 7 | type: string 8 | default: "[\"1.14.x\"]" 9 | erlangs: 10 | type: string 11 | default: "[\"25.x\"]" 12 | 13 | env: 14 | MIX_ENV: test 15 | 16 | jobs: 17 | h2spec: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | elixir: ${{ fromJSON(inputs.elixirs) }} 23 | otp: ${{ fromJSON(inputs.erlangs) }} 24 | exclude: 25 | - elixir: 1.12.x 26 | otp: 25.x 27 | - elixir: 1.14.x 28 | otp: 23.x 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | - name: Setup Elixir 33 | uses: erlef/setup-beam@v1 34 | with: 35 | elixir-version: ${{ matrix.elixir }} 36 | otp-version: ${{ matrix.otp }} 37 | - name: Disable compile warnings 38 | run: echo "::remove-matcher owner=elixir-mixCompileWarning::" 39 | - name: Retrieve mix dependencies cache 40 | uses: actions/cache@v3 41 | id: mix-cache 42 | with: 43 | path: | 44 | deps 45 | _build 46 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 47 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- 48 | - name: Install mix dependencies 49 | if: steps.mix-cache.outputs.cache-hit != 'true' 50 | run: | 51 | mix deps.get 52 | - name: Run h2spec test 53 | run: mix test --only external_conformance test/bandit/http2/h2spec_test.exs 54 | -------------------------------------------------------------------------------- /test/bandit/.github/workflows/manual_benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Manual Benchmark 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | profile: 7 | description: Which profile of test to run 8 | required: true 9 | default: 'normal' 10 | type: choice 11 | options: 12 | - 'normal' 13 | - 'tiny' 14 | - 'huge' 15 | protocol: 16 | description: Which protocol to test 17 | required: true 18 | default: 'http/1.1,h2c,ws' 19 | type: choice 20 | options: 21 | - 'http/1.1,h2c,ws' 22 | - 'http/1.1' 23 | - 'h2c' 24 | - 'ws' 25 | baseline_server: 26 | description: Which server to use as baseline 27 | required: true 28 | default: 'bandit' 29 | type: choice 30 | options: 31 | - bandit 32 | - cowboy 33 | baseline_version: 34 | description: Which version of the baseline to use 35 | required: true 36 | default: 'main' 37 | test_server: 38 | description: Which server to test 39 | required: true 40 | default: 'bandit' 41 | type: choice 42 | options: 43 | - bandit 44 | - cowboy 45 | test_version: 46 | description: Which version of the server to test 47 | required: true 48 | default: 'main' 49 | 50 | run-name: ${{ inputs.test_server }}@${{ inputs.test_version }} vs ${{ inputs.baseline_server }}@${{ inputs.baseline_version }} (${{ inputs.profile }}) 51 | 52 | env: 53 | MIX_ENV: test 54 | 55 | jobs: 56 | benchmark: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Install nghttp2 60 | run: sudo apt-get install nghttp2 61 | - name: Checkout code 62 | uses: actions/checkout@v4 63 | with: 64 | repository: mtrudel/benchmark 65 | - name: Setup Elixir 66 | uses: erlef/setup-beam@v1 67 | with: 68 | elixir-version: 1.14.x 69 | otp-version: 25.1 70 | - name: Install mix dependencies 71 | run: mix deps.get 72 | - name: Compile code 73 | run: mix compile 74 | - name: Run benchmark 75 | run: mix benchmark --profile ${{ inputs.profile }} --protocol ${{ inputs.protocol }} ${{ inputs.baseline_server }}@${{ inputs.baseline_version }} ${{ inputs.test_server }}@${{ inputs.test_version }} 76 | - name: Upload results file 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: http-benchmark.csv 80 | path: http-benchmark.csv 81 | - name: Record summary output 82 | id: summary 83 | run: cat http-summary.md >> $GITHUB_STEP_SUMMARY 84 | -------------------------------------------------------------------------------- /test/bandit/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | bandit-*.tar 24 | 25 | # Ignore dialyzer caches 26 | /priv/plts/ 27 | -------------------------------------------------------------------------------- /test/bandit/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mat Trudel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/bandit/README.md: -------------------------------------------------------------------------------- 1 | # Bandit HTTP/1.1 Test Suite 2 | 3 | This subset of the repo is used for compliance testing with the HTTP/1.1 spec 4 | as we believe that Bandit is doing great there. 5 | 6 | All rights go to [mtrudel](https://github.com/mtrudel) and the Bandit 7 | contributors for this work. 8 | 9 | -------------------------------------------------------------------------------- /test/bandit/lib/bandit_headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Bandit.Headers do 2 | @moduledoc false 3 | # Conveniences for dealing with headers. 4 | 5 | @spec is_port_number(integer()) :: Macro.t() 6 | defguardp is_port_number(port) when Bitwise.band(port, 0xFFFF) === port 7 | 8 | @spec get_header(Plug.Conn.headers(), header :: binary()) :: binary() | nil 9 | def get_header(headers, header) do 10 | case List.keyfind(headers, header, 0) do 11 | {_, value} -> value 12 | nil -> nil 13 | end 14 | end 15 | 16 | # Covers IPv6 addresses, like `[::1]:4000` as defined in RFC3986. 17 | @spec parse_hostlike_header(host_header :: binary()) :: 18 | {:ok, Plug.Conn.host(), nil | Plug.Conn.port_number()} | {:error, String.t()} 19 | def parse_hostlike_header("[" <> _ = host_header) do 20 | host_header 21 | |> :binary.split("]:") 22 | |> case do 23 | [host, port] -> 24 | case parse_integer(port) do 25 | {port, ""} when is_port_number(port) -> {:ok, host <> "]", port} 26 | _ -> {:error, "Header contains invalid port"} 27 | end 28 | 29 | [host] -> 30 | {:ok, host, nil} 31 | end 32 | end 33 | 34 | def parse_hostlike_header(host_header) do 35 | host_header 36 | |> :binary.split(":") 37 | |> case do 38 | [host, port] -> 39 | case parse_integer(port) do 40 | {port, ""} when is_port_number(port) -> {:ok, host, port} 41 | _ -> {:error, "Header contains invalid port"} 42 | end 43 | 44 | [host] -> 45 | {:ok, host, nil} 46 | end 47 | end 48 | 49 | @spec get_content_length(Plug.Conn.headers()) :: 50 | {:ok, nil | non_neg_integer()} | {:error, String.t()} 51 | def get_content_length(headers) do 52 | case get_header(headers, "content-length") do 53 | nil -> {:ok, nil} 54 | value -> parse_content_length(value) 55 | end 56 | end 57 | 58 | @spec get_connection_header_keys(Plug.Conn.headers()) :: 59 | {:ok, [String.t()]} | {:error, String.t()} 60 | def get_connection_header_keys(headers) do 61 | case Bandit.Headers.get_header(headers, "connection") do 62 | nil -> 63 | {:error, "Expected connection header"} 64 | 65 | value -> 66 | header_keys = 67 | value 68 | |> String.downcase() 69 | |> Plug.Conn.Utils.list() 70 | 71 | {:ok, header_keys} 72 | end 73 | end 74 | 75 | @spec parse_content_length(binary()) :: {:ok, non_neg_integer()} | {:error, String.t()} 76 | defp parse_content_length(value) do 77 | case parse_integer(value) do 78 | {length, ""} -> 79 | {:ok, length} 80 | 81 | {length, _rest} -> 82 | if value |> Plug.Conn.Utils.list() |> Enum.all?(&(&1 == to_string(length))), 83 | do: {:ok, length}, 84 | else: {:error, "invalid content-length header (RFC9112§6.3.5)"} 85 | 86 | :error -> 87 | {:error, "invalid content-length header (RFC9112§6.3.5)"} 88 | end 89 | end 90 | 91 | # Parses non-negative integers from strings. Return the valid portion of an 92 | # integer and the remaining string as a tuple like `{123, ""}` or `:error`. 93 | @spec parse_integer(String.t()) :: {non_neg_integer(), rest :: String.t()} | :error 94 | defp parse_integer(<>) when digit >= ?0 and digit <= ?9 do 95 | parse_integer(rest, digit - ?0) 96 | end 97 | 98 | defp parse_integer(_), do: :error 99 | 100 | @spec parse_integer(String.t(), non_neg_integer()) :: {non_neg_integer(), String.t()} 101 | defp parse_integer(<>, total) when digit >= ?0 and digit <= ?9 do 102 | parse_integer(rest, total * 10 + digit - ?0) 103 | end 104 | 105 | defp parse_integer(rest, total), do: {total, rest} 106 | 107 | @spec add_content_length(Plug.Conn.headers(), non_neg_integer(), Plug.Conn.int_status()) :: 108 | Plug.Conn.headers() 109 | def add_content_length(headers, length, status) do 110 | headers = Enum.reject(headers, &(elem(&1, 0) == "content-length")) 111 | 112 | if add_content_length?(status), 113 | do: [{"content-length", to_string(length)} | headers], 114 | else: headers 115 | end 116 | 117 | # Per RFC9110§8.6 118 | @spec add_content_length?(Plug.Conn.int_status()) :: boolean() 119 | defp add_content_length?(status) when status in 100..199, do: false 120 | defp add_content_length?(204), do: false 121 | defp add_content_length?(304), do: false 122 | defp add_content_length?(_), do: true 123 | end 124 | -------------------------------------------------------------------------------- /test/bandit/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Bandit.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :bandit, 7 | version: "1.1.2", 8 | elixir: "~> 1.13", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | elixirc_paths: elixirc_path(Mix.env()), 12 | dialyzer: dialyzer(), 13 | name: "Bandit", 14 | description: "A pure-Elixir HTTP server built for Plug & WebSock apps", 15 | source_url: "https://github.com/mtrudel/bandit", 16 | package: [ 17 | maintainers: ["Mat Trudel"], 18 | licenses: ["MIT"], 19 | links: %{"GitHub" => "https://github.com/mtrudel/bandit"}, 20 | files: ["lib", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"] 21 | ], 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application do 27 | [extra_applications: [:logger]] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:thousand_island, "~> 1.0"}, 33 | {:plug, "~> 1.14"}, 34 | {:websock, "~> 0.5"}, 35 | {:hpax, "~> 0.1.1"}, 36 | {:telemetry, "~> 0.4 or ~> 1.0"}, 37 | {:req, "~> 0.3", only: [:dev, :test]}, 38 | {:machete, ">= 0.0.0", only: [:dev, :test]}, 39 | {:ex_doc, "~> 0.24", only: [:dev, :test], runtime: false}, 40 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 41 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, 42 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false} 43 | ] 44 | end 45 | 46 | defp elixirc_path(:test), do: ["lib/", "test/support"] 47 | defp elixirc_path(_), do: ["lib/"] 48 | 49 | defp dialyzer do 50 | [ 51 | plt_core_path: "priv/plts", 52 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 53 | plt_add_deps: :apps_direct, 54 | plt_add_apps: [:ssl, :public_key], 55 | flags: [ 56 | "-Werror_handling", 57 | "-Wextra_return", 58 | "-Wmissing_return", 59 | "-Wunknown", 60 | "-Wunmatched_returns", 61 | "-Wunderspecs" 62 | ] 63 | ] 64 | end 65 | 66 | defp docs do 67 | [ 68 | extras: [ 69 | "CHANGELOG.md": [title: "Changelog"], 70 | "README.md": [title: "README"], 71 | "lib/bandit/http1/README.md": [ 72 | filename: "HTTP1_README.md", 73 | title: "HTTP/1 Implementation Notes" 74 | ], 75 | "lib/bandit/http2/README.md": [ 76 | filename: "HTTP2_README.md", 77 | title: "HTTP/2 Implementation Notes" 78 | ], 79 | "lib/bandit/websocket/README.md": [ 80 | filename: "WebSocket_README.md", 81 | title: "WebSocket Implementation Notes" 82 | ] 83 | ], 84 | groups_for_extras: [ 85 | "Implementation Notes": Path.wildcard("lib/bandit/*/README.md") 86 | ], 87 | skip_undefined_reference_warnings_on: Path.wildcard("**/*.md"), 88 | main: "Bandit", 89 | logo: "assets/ex_doc_logo.png" 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/bandit/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, 4 | "credo": {:hex, :credo, "1.7.2", "fdee3a7cb553d8f2e773569181f0a4a2bb7d192e27e325404cc31b354f59d68c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd15d6fbc280f6cf9b269f41df4e4992dee6615939653b164ef951f60afcb68e"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.31.0", "06eb1dfd787445d9cab9a45088405593dd3bb7fe99e097eaa71f37ba80c7a676", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5350cafa6b7f77bdd107aa2199fe277acf29d739aba5aee7e865fc680c62a110"}, 9 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 10 | "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, 11 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 12 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 13 | "machete": {:hex, :machete, "0.3.0", "b0717d6a3792b2596d69455fe199fd2bf6cd5ea8f9f05845ec95a75184b339e6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8b32b9376103112d118446e9823fd680ca064193abbeab67f05ac67989551499"}, 14 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, 17 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 18 | "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, 19 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.1", "eee6fc570d77ad6851c7bc08de420a47fd1e449ef5ccfa6a77ef68b72e7e51ad", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f82262b54dee533467021723892e15c3267349849f1f737526523ecba4e6baae"}, 20 | "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 22 | "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, 23 | "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, 24 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 25 | "req": {:hex, :req, "0.4.8", "2b754a3925ddbf4ad78c56f30208ced6aefe111a7ea07fb56c23dccc13eb87ae", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7146e51d52593bb7f20d00b5308a5d7d17d663d6e85cd071452b613a8277100c"}, 26 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 27 | "thousand_island": {:hex, :thousand_island, "1.3.2", "bc27f9afba6e1a676dd36507d42e429935a142cf5ee69b8e3f90bff1383943cd", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e085b93012cd1057b378fce40cbfbf381ff6d957a382bfdd5eca1a98eec2535"}, 28 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/bandit/test/support/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICyjCCAbICCQCRKhJFmW6v0zANBgkqhkiG9w0BAQsFADAnMQswCQYDVQQGEwJV 3 | UzEYMBYGA1UEAwwPRXhhbXBsZS1Sb290LUNBMB4XDTIxMDYwNjAzMDcxMVoXDTI3 4 | MDExNDAzMDcxMVowJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9v 5 | dC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOdFzns6VXOtAsVw 6 | CxAVWFbfEEDzzJemaCmcnW7xxcPu/NutEKbrZzDPUW8FcRTK90i81rffcT12hEFV 7 | k66zVWVOxYFAGxqYhvrOaJlehwlWdmGCl2740UdsvXsziNXuKFYmZi3/8+rV4UJY 8 | s/Ipv61a8x/2KagCYykr2G03iAiF/C2j//xpjrUfSuGMu/lapDXxQfDsAGtd7qSc 9 | HHEMzBvLq8cLuwVJFQEBqvEU3Nkn3gAeW7gC6myGRH3c68mbWmY/r5RcS7KdFjsD 10 | BVMGZXOtzLozXzQqI/G4DmTxKvYqFN/pjz9PZ8vQFkr9ddrC09kNmxCcCbC3WQfa 11 | 3FihVM0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA1oErpO9Co61Wb+6mqYyFMZpy 12 | Bedyf3SIQzQlDig+R/+avs7ZMmZtu0A7LjMPdgpfrJhlBK/1ThkNRYRsMbnyku+F 13 | qC1XEnrfRG9PXYcxigH6DLTaHSfYVE5QQFQAZequiQb6K0LVWUcuPVkzZNGMl3RR 14 | qb9/pM2hCwwNApnZYi4SzH6tfhF6OFi5dNexCEqqtKd00ZZh09ERhnqZpR62jQS/ 15 | SERs6ZS9K9D+A9f/4cRSJOFB9gSfil0sqxBC5GhO6Bse5HEprlPkHPeoHMD1Mj1K 16 | zH1twf4Amnz3p9OGRZZ6qspkXhT+8gTIfzlcX/wJpv7c53uI/OAIxRwogI0w7Q== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /test/bandit/test/support/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDijCCAnKgAwIBAgIJAI8sEm5tQlsrMA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwHhcNMjEwNjA2MDMwODMw 4 | WhcNNDgxMDIyMDMwODMwWjBtMQswCQYDVQQGEwJVUzESMBAGA1UECAwJWW91clN0 5 | YXRlMREwDwYDVQQHDAhZb3VyQ2l0eTEdMBsGA1UECgwURXhhbXBsZS1DZXJ0aWZp 6 | Y2F0ZXMxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEB 7 | BQADggEPADCCAQoCggEBAPN+9Lo3Z1DGmMLzj/5nb+48Wx4Ra4xBooeMyO22IqEu 8 | 93y4clasT8YZYoUh283AUUGajZaggia19HKSKAzmu2SFi4fRoqYYtyztsvm2qih2 9 | ORPGOJo0UUE7q5alM4RtgnNoAuPnjZ2eYAMEflt80K0X8TYAAfZ3wvfHEL5y8NDd 10 | 8LrBKsSVmE14bq7OzHMNPGuHEsTk/ESjBtJehQkQ1eT02TCgPPudTIky4jkkFQ/F 11 | OiMurScH+GsauKRZqSbhzv8a30FBl/50cTbnQb9KSfg0DjKEbP51NIX+PuSt9KlL 12 | 49r7AsKpSNqmpuVGSmInRVx+XOo4Ytt2Deu6lQAUzU8CAwEAAaNzMHEwQQYDVR0j 13 | BDowOKErpCkwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1D 14 | QYIJAJEqEkWZbq/TMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuC 15 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAuTlHn/ytxq0QVtvFlEUXfWFi 16 | z0JUxTty7cwXPVVqxIYh5fVip6GYjlJgaQiMPeMneWbne6DtXckLvsje4XrjGeZO 17 | BYrsA4i6Ik6idxw3/AVyncJsNeA8fkEzyxFRUoAOLRrS7pb1EkuakgGuVv3c/gTa 18 | E1bAHzqQyEWW3i2H5hKBSjy5KD61MMcmD006dmypxmwaLmted28cgvqVR9fdU/5p 19 | vl6rnqUxEmTnKzX9LX4NQQR3lodyhj7zMVcL8ozC39YQ15oOSneDtMOweWmMAgE6 20 | idRfFBX/fBaprJKRAR9TGCCXcOO/cA9QTkI31iCdWCuqeCpKHtFSbpI8cehMZQ== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test/bandit/test/support/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDzfvS6N2dQxpjC 3 | 84/+Z2/uPFseEWuMQaKHjMjttiKhLvd8uHJWrE/GGWKFIdvNwFFBmo2WoIImtfRy 4 | kigM5rtkhYuH0aKmGLcs7bL5tqoodjkTxjiaNFFBO6uWpTOEbYJzaALj542dnmAD 5 | BH5bfNCtF/E2AAH2d8L3xxC+cvDQ3fC6wSrElZhNeG6uzsxzDTxrhxLE5PxEowbS 6 | XoUJENXk9NkwoDz7nUyJMuI5JBUPxTojLq0nB/hrGrikWakm4c7/Gt9BQZf+dHE2 7 | 50G/Skn4NA4yhGz+dTSF/j7krfSpS+Pa+wLCqUjapqblRkpiJ0VcflzqOGLbdg3r 8 | upUAFM1PAgMBAAECggEAL2mEC5JoKqFQ83zrh9TqRZA5CcTIlTnehNhT831ohswX 9 | YpCjqt7IdcFRnqy2GP0elVCbyz2buh/p5jkxVTnEOVGLlrmqGv9rA3ORSvBXd6N1 10 | f7U0JkqTm8kboyytuFZ+dSxGi8v1lkBVX6ELXZMTKvEjhalAuJYfP5HiX8MPwwti 11 | 540zHN4fQ9cgDkpjLyR+g4us1iBtYmbx/+BHeE6tsN5I0dc4Ee/LxXLkBRqNhB4K 12 | g6wWynqLtX12py/TFaBfs8DSMY1IfdCxJDxiahtGCXFokfsQbqBySpLrdx41ela4 13 | IG5qr0u9/XyX4Y6pm4chNxKMwqRRF9bllGXEI2HySQKBgQD/pY43H3gPM/8IMzcT 14 | f3YvBX1KliU26xdbjPGrp+lAsUcAEv0gNmYsr6lUFZNd3RmsqfYR7Rt2GE468i95 15 | gfgRmTXUrDbZKeu92qvZNfd9f3HJYsjcHhSNNtRSYNubjpwniU4zyl78Jjz4jv44 16 | xNjEtmQ//FNTIIaKAr5zQ8lTxQKBgQDz1RoFwkJs38+LfLotjmNcmV7vC4Q4H3ZG 17 | m6mRNTfAiwFy9kbF16d4+fZ/byoHxSAOEjH08sKjQ1koH0moFQaFXWDW7/gwIiq3 18 | ErKO8ZE53uazFGOhNmHMdkdaTwb/Hn5/DKJBBexY/2O8S+tIZchhB0hluiz3QKSo 19 | p/rbcDiqAwKBgQDAXDFrltk/D0/qOqdJm5IxBX9mPR4ZecHkmGRMVpczn3EeRCuF 20 | LompPDA8XdO6QCEOhADtMi2EqftLbWp9kmc3zsHrmf3XYCzLeZvvYCUuoFPdReB/ 21 | iH7MVyJiLhFwtlkXgsB+Rds8/gTIvsfZrXyyX8+FOfb0yLeTZ0co8iuuRQKBgCgG 22 | hT0IxGqm2qTlFpK/2uOqcYD//PZRg9LXXqBtgfdjWhuK/dcgLWeYcLQ+hUG9RCPL 23 | LNQuvXCbb5k8eZTTzrw5tdnSjoUoNqbStOjuEo7TXj9rS2d9S9SKXfAfJODgGpe0 24 | dTYDSObbFX4lYDwEKT50OZgpVZRI0j61RGKdK1ANAoGBAKoO8Sbm6O9KTfvgVuQf 25 | t/L1c47JI6GnX1i9JCgQxRapmEYEupt0hRJh36zSvnip+iy8J22useJFtCcxeZUj 26 | XOH1WOWwQn8qDSUPA3PVO3TZb+k4Z8VlIKzrEWHLl56zWPO5Su7AHXseJhY1Z4Bz 27 | WmUvA9kdCmStQ7RMH89NRnh8 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /test/bandit/test/support/noop_sock.ex: -------------------------------------------------------------------------------- 1 | defmodule NoopWebSock do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | @behaviour WebSock 7 | 8 | @impl true 9 | def init(arg), do: {:ok, arg} 10 | 11 | @impl true 12 | def handle_in(_data, state), do: {:ok, state} 13 | 14 | @impl true 15 | def handle_info(_msg, state), do: {:ok, state} 16 | 17 | @impl true 18 | def terminate(_reason, _state), do: :ok 19 | 20 | defoverridable init: 1, handle_in: 2, handle_info: 2, terminate: 2 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/bandit/test/support/req_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ReqHelpers do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote location: :keep do 6 | def req_http1_client(context) do 7 | start_finch(context) 8 | [req: build_req(context)] 9 | end 10 | 11 | def req_h2_client(context) do 12 | start_finch(context, protocol: :http2) 13 | [req: build_req(context)] 14 | end 15 | 16 | defp start_finch(context, overrides \\ []) do 17 | options = 18 | [ 19 | conn_opts: [ 20 | transport_opts: [ 21 | verify: :verify_peer, 22 | cacertfile: Path.join(__DIR__, "../support/ca.pem") 23 | ] 24 | ] 25 | ] 26 | |> Keyword.merge(overrides) 27 | 28 | start_supervised!({Finch, name: context.test, pools: %{default: options}}) 29 | end 30 | 31 | defp build_req(context) do 32 | Req.Request.new([]) 33 | |> Req.Request.append_request_steps(base_url: &Req.Steps.put_base_url/1) 34 | |> Req.Request.register_options([:base_url, :finch]) 35 | |> Req.Request.merge_options( 36 | base_url: context.base, 37 | finch: context.test 38 | ) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/bandit/test/support/sendfile: -------------------------------------------------------------------------------- 1 | ABCDEF -------------------------------------------------------------------------------- /test/bandit/test/support/sendfile_large: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vulputate eleifend vestibulum. Maecenas sollicitudin eget enim vel rutrum. Sed dictum feugiat ex ut venenatis. Etiam sollicitudin dignissim magna sit amet commodo. Quisque sodales metus turpis, vel posuere eros convallis nec. Nunc dapibus non neque at fermentum. Aenean augue leo, ultrices sed velit quis, fermentum lacinia diam. 2 | 3 | Cras suscipit, felis vitae consectetur ullamcorper, nunc quam faucibus metus, gravida suscipit erat dolor in dolor. Duis pulvinar felis rhoncus vulputate bibendum. Nam urna ex, blandit non eros quis, blandit viverra tortor. Nullam aliquam, lectus a tempus accumsan, sapien tellus efficitur quam, eu hendrerit nibh nunc at augue. Duis sed rutrum est. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras dictum vulputate massa non sagittis. Fusce vel arcu lacus. Nulla venenatis leo a orci tempor, posuere maximus mi efficitur. Duis vel magna ac eros posuere dictum. 4 | 5 | Maecenas dictum porttitor lobortis. Ut ullamcorper tempus risus, non pellentesque leo ultrices et. Integer posuere sodales sapien, a feugiat turpis mollis at. Curabitur elementum pharetra egestas. Integer lobortis augue diam, ut tincidunt mauris feugiat consequat. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque suscipit lectus non ipsum tempor convallis. Integer tincidunt lacus et blandit pharetra. In vehicula pretium ipsum, mollis condimentum arcu consectetur sit amet. Quisque malesuada, risus in dapibus vulputate, metus ex consectetur mauris, sed eleifend est neque ac neque. Ut ac finibus lectus. Nam et ornare tellus, sit amet vehicula magna. 6 | 7 | Aliquam maximus elementum interdum. Ut dictum pretium bibendum. Sed vel tempus massa. Donec consequat consectetur ex, sed convallis velit rutrum quis. Fusce ut laoreet libero, nec auctor urna. Etiam porttitor dapibus nulla, at lobortis neque varius et. Curabitur laoreet arcu lobortis, tristique justo id, mollis eros. Proin tellus arcu, dapibus id leo sed, bibendum rhoncus mi. Suspendisse ac eleifend nisl, a tempus urna. Maecenas lobortis lacus nisi, sed faucibus augue varius id. Aenean ut orci euismod risus fermentum ornare ac ut ex. Nam feugiat elit ex, vel maximus massa pretium nec. Morbi aliquet nisl nec sem tempor, vel bibendum ligula porta. Donec pharetra eget lectus ut rhoncus. 8 | 9 | Morbi a bibendum enim, a blandit ipsum. Integer in mi lacinia, pulvinar purus a, aliquet tortor. Donec ex lacus, semper commodo neque at, mattis finibus nisl. Maecenas sollicitudin malesuada massa sed bibendum. Fusce massa est, malesuada quis purus at, porta mollis sapien. Phasellus in lorem aliquet, tempor dolor non, iaculis purus. Maecenas vulputate ac risus sit amet aliquet. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Phasellus aliquet leo porta sapien tincidunt, in dignissim massa faucibus. Praesent risus quam, ornare commodo orci in, iaculis turpis. -------------------------------------------------------------------------------- /test/bandit/test/support/server_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ServerHelpers do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote location: :keep do 6 | import Plug.Conn 7 | 8 | def http_server(context, opts \\ []) do 9 | port = 2112 10 | [base: "http://localhost:#{port}", port: port] 11 | end 12 | 13 | def https_server(context, opts \\ []) do 14 | port = 2112 15 | [base: "https://localhost:#{port}", port: port] 16 | end 17 | 18 | def init(opts) do 19 | opts 20 | end 21 | 22 | def call(conn, []) do 23 | function = String.to_atom(List.first(conn.path_info)) 24 | apply(__MODULE__, function, [conn]) 25 | end 26 | 27 | defoverridable init: 1, call: 2 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/bandit/test/support/simple_h2_client.ex: -------------------------------------------------------------------------------- 1 | defmodule SimpleH2Client do 2 | @moduledoc false 3 | 4 | import Bitwise 5 | 6 | def tls_client(context), do: Transport.tls_client(context, ["h2"]) 7 | 8 | def setup_connection(context) do 9 | socket = tls_client(context) 10 | exchange_prefaces(socket) 11 | exchange_client_settings(socket) 12 | socket 13 | end 14 | 15 | def exchange_prefaces(socket) do 16 | Transport.send(socket, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") 17 | {:ok, <<0, 0, 0, 4, 0, 0, 0, 0, 0>>} = Transport.recv(socket, 9) 18 | Transport.send(socket, <<0, 0, 0, 4, 1, 0, 0, 0, 0>>) 19 | end 20 | 21 | def exchange_client_settings(socket, settings \\ <<>>) do 22 | Transport.send(socket, <>) 23 | Transport.send(socket, settings) 24 | {:ok, <<0, 0, 0, 4, 1, 0, 0, 0, 0>>} = Transport.recv(socket, 9) 25 | end 26 | 27 | def connection_alive?(socket) do 28 | Transport.send(socket, <<0, 0, 8, 6, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8>>) 29 | Transport.recv(socket, 17) == {:ok, <<0, 0, 8, 6, 1, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8>>} 30 | end 31 | 32 | def recv_goaway_and_close(socket) do 33 | {:ok, <<0, 0, 8, 7, 0, 0, 0, 0, 0, last_stream_id::32, error_code::32>>} = 34 | Transport.recv(socket, 17) 35 | 36 | {:error, :closed} = Transport.recv(socket, 0) 37 | 38 | {:ok, last_stream_id, error_code} 39 | end 40 | 41 | def send_goaway(socket, last_stream_id, error_code) do 42 | Transport.send(socket, <<0, 0, 8, 7, 0, 0, 0, 0, 0, last_stream_id::32, error_code::32>>) 43 | end 44 | 45 | def send_simple_headers(socket, stream_id, verb, path, port, ctx \\ HPAX.new(4096)) do 46 | {verb, end_stream} = 47 | case verb do 48 | :get -> {"GET", true} 49 | :head -> {"HEAD", true} 50 | :post -> {"POST", false} 51 | end 52 | 53 | send_headers( 54 | socket, 55 | stream_id, 56 | end_stream, 57 | [ 58 | {":method", verb}, 59 | {":path", path}, 60 | {":scheme", "https"}, 61 | {":authority", "localhost:#{port}"} 62 | ], 63 | ctx 64 | ) 65 | end 66 | 67 | def send_headers(socket, stream_id, end_stream, headers, ctx \\ HPAX.new(4096)) do 68 | {headers, _} = headers |> Enum.map(fn {k, v} -> {:store, k, v} end) |> HPAX.encode(ctx) 69 | flags = if end_stream, do: 0x05, else: 0x04 70 | 71 | Transport.send(socket, [ 72 | <>, 73 | headers 74 | ]) 75 | 76 | {:ok, ctx} 77 | end 78 | 79 | def send_priority(socket, stream_id, dependent_stream_id, weight) do 80 | Transport.send(socket, <<0, 0, 5, 2, 0, stream_id::32, dependent_stream_id::32, weight::8>>) 81 | end 82 | 83 | def successful_response?(socket, stream_id, end_stream, ctx \\ HPAX.new(4096)) do 84 | {:ok, ^stream_id, ^end_stream, [{":status", "200"} | _], _ctx} = recv_headers(socket, ctx) 85 | end 86 | 87 | def recv_headers(socket, ctx \\ HPAX.new(4096)) do 88 | {:ok, <>} = Transport.recv(socket, 9) 89 | {:ok, header_block} = Transport.recv(socket, length) 90 | {:ok, headers, ctx} = HPAX.decode(header_block, ctx) 91 | {:ok, stream_id, (flags &&& 0x01) == 0x01, headers, ctx} 92 | end 93 | 94 | def send_body(socket, stream_id, end_stream, body) do 95 | flags = if end_stream, do: 0x01, else: 0x00 96 | 97 | Transport.send(socket, [ 98 | <>, 99 | body 100 | ]) 101 | end 102 | 103 | def send_window_update(socket, stream_id, increment) do 104 | Transport.send(socket, <<4::24, 8::8, 0::8, 0::1, stream_id::31, 0::1, increment::31>>) 105 | end 106 | 107 | def recv_window_update(socket) do 108 | {:ok, <<4::24, 8::8, 0::8, 0::1, stream_id::31, 0::1, update::31>>} = 109 | Transport.recv(socket, 13) 110 | 111 | {:ok, stream_id, update} 112 | end 113 | 114 | def recv_body(socket) do 115 | {:ok, <>} = Transport.recv(socket, 9) 116 | 117 | if body_length == 0 do 118 | {:ok, stream_id, (flags &&& 0x01) == 0x01, <<>>} 119 | else 120 | {:ok, body} = Transport.recv(socket, body_length) 121 | {:ok, stream_id, (flags &&& 0x01) == 0x01, body} 122 | end 123 | end 124 | 125 | def send_rst_stream(socket, stream_id, error_code) do 126 | Transport.send(socket, [<<0, 0, 4, 3, 0, 0::1, stream_id::31>>, <>]) 127 | end 128 | 129 | def recv_rst_stream(socket) do 130 | {:ok, <<0, 0, 4, 3, 0, 0::1, stream_id::31, error_code::32>>} = Transport.recv(socket, 13) 131 | {:ok, stream_id, error_code} 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/bandit/test/support/simple_http1_client.ex: -------------------------------------------------------------------------------- 1 | defmodule SimpleHTTP1Client do 2 | @moduledoc false 3 | 4 | defdelegate tcp_client(context), to: Transport 5 | 6 | def send(socket, verb, request_target, headers \\ [], version \\ "1.1") do 7 | Transport.send(socket, "#{verb} #{request_target} HTTP/#{version}\r\n") 8 | Enum.each(headers, &Transport.send(socket, &1 <> "\r\n")) 9 | Transport.send(socket, "\r\n") 10 | end 11 | 12 | def recv_reply(socket, head? \\ false) do 13 | {:ok, response} = Transport.recv(socket, 0) 14 | parse_response(socket, response, head?) 15 | end 16 | 17 | def parse_response(socket, response, head? \\ false) do 18 | [status_line | headers] = String.split(response, "\r\n") 19 | <<_version::binary-size(8), " ", status::binary>> = status_line 20 | {headers, rest} = Enum.split_while(headers, &(&1 != "")) 21 | 22 | headers = 23 | Enum.map(headers, fn header -> 24 | [key, value] = String.split(header, ":", parts: 2) 25 | {String.to_atom(key), String.trim(value)} 26 | end) 27 | 28 | rest = rest |> Enum.drop(1) |> Enum.join("\r\n") 29 | 30 | body = 31 | headers 32 | |> Keyword.get(:"content-length") 33 | |> case do 34 | _ when head? -> 35 | rest 36 | 37 | nil -> 38 | rest 39 | 40 | value -> 41 | case String.to_integer(value) - byte_size(rest) do 42 | 0 -> 43 | rest 44 | 45 | pending when pending < 0 -> 46 | expected = String.to_integer(value) 47 | <> = rest 48 | response 49 | 50 | pending -> 51 | {:ok, response} = Transport.recv(socket, pending) 52 | rest <> response 53 | end 54 | end 55 | 56 | {:ok, status, headers, body} 57 | end 58 | 59 | def connection_closed_for_reading?(client) do 60 | Transport.recv(client, 0) == {:error, :closed} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/bandit/test/support/simple_websocket_client.ex: -------------------------------------------------------------------------------- 1 | defmodule SimpleWebSocketClient do 2 | @moduledoc false 3 | 4 | alias Bandit.WebSocket.Frame 5 | 6 | defdelegate tcp_client(context), to: Transport 7 | 8 | def http1_handshake(client, module, params \\ [], deflate \\ false) do 9 | params = Keyword.put(params, :websock, module) 10 | params = if deflate, do: Keyword.put(params, :compress, "true"), else: params 11 | extension_header = if deflate, do: ["Sec-WebSocket-Extensions: permessage-deflate"], else: [] 12 | 13 | SimpleHTTP1Client.send( 14 | client, 15 | "GET", 16 | "/?#{URI.encode_query(params)}", 17 | [ 18 | "Host: server.example.com", 19 | "Upgrade: WeBsOcKeT", 20 | "Connection: UpGrAdE", 21 | "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", 22 | "Sec-WebSocket-Version: 13" 23 | ] ++ extension_header 24 | ) 25 | 26 | # Because we don't want to consume any more than our headers, we can't use SimpleHTTP1Client 27 | {:ok, response} = Transport.recv(client, 239) 28 | 29 | [ 30 | "HTTP/1.1 101 Switching Protocols", 31 | "date: " <> _date, 32 | "vary: accept-encoding", 33 | "cache-control: max-age=0, private, must-revalidate", 34 | "upgrade: websocket", 35 | "connection: Upgrade", 36 | "sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK\+xOo=", 37 | "" 38 | ] = String.split(response, "\r\n") 39 | 40 | case Transport.recv(client, 2) do 41 | {:ok, "\r\n"} -> 42 | {:ok, false} 43 | 44 | {:ok, "se"} when deflate -> 45 | {:ok, "c-websocket-extensions: permessage-deflate\r\n\r\n"} = Transport.recv(client, 46) 46 | {:ok, true} 47 | end 48 | end 49 | 50 | def connection_closed_for_reading?(client) do 51 | Transport.recv(client, 0) == {:error, :closed} 52 | end 53 | 54 | def connection_closed_for_writing?(client) do 55 | Transport.send(client, <<>>) == {:error, :closed} 56 | end 57 | 58 | def recv_text_frame(client) do 59 | {:ok, 0x8, 0x1, body} = recv_frame(client) 60 | {:ok, body} 61 | end 62 | 63 | def recv_deflated_text_frame(client) do 64 | {:ok, 0xC, 0x1, body} = recv_frame(client) 65 | {:ok, body} 66 | end 67 | 68 | def recv_binary_frame(client) do 69 | {:ok, 0x8, 0x2, body} = recv_frame(client) 70 | {:ok, body} 71 | end 72 | 73 | def recv_deflated_binary_frame(client) do 74 | {:ok, 0xC, 0x2, body} = recv_frame(client) 75 | {:ok, body} 76 | end 77 | 78 | def recv_connection_close_frame(client) do 79 | {:ok, 0x8, 0x8, body} = recv_frame(client) 80 | {:ok, body} 81 | end 82 | 83 | def recv_ping_frame(client) do 84 | {:ok, 0x8, 0x9, body} = recv_frame(client) 85 | {:ok, body} 86 | end 87 | 88 | def recv_pong_frame(client) do 89 | {:ok, 0x8, 0xA, body} = recv_frame(client) 90 | {:ok, body} 91 | end 92 | 93 | defp recv_frame(client) do 94 | {:ok, header} = Transport.recv(client, 2) 95 | <> = header 96 | 97 | {:ok, data} = 98 | case length do 99 | 0 -> 100 | {:ok, <<>>} 101 | 102 | 126 -> 103 | {:ok, <>} = Transport.recv(client, 2) 104 | Transport.recv(client, length) 105 | 106 | 127 -> 107 | {:ok, <>} = Transport.recv(client, 8) 108 | Transport.recv(client, length) 109 | 110 | length -> 111 | Transport.recv(client, length) 112 | end 113 | 114 | {:ok, flags, opcode, data} 115 | end 116 | 117 | def send_continuation_frame(client, data, flags \\ 0x8) do 118 | send_frame(client, flags, 0x0, data) 119 | end 120 | 121 | def send_text_frame(client, data, flags \\ 0x8) do 122 | send_frame(client, flags, 0x1, data) 123 | end 124 | 125 | def send_binary_frame(client, data, flags \\ 0x8) do 126 | send_frame(client, flags, 0x2, data) 127 | end 128 | 129 | def send_connection_close_frame(client, reason) do 130 | send_frame(client, 0x8, 0x8, <>) 131 | end 132 | 133 | def send_ping_frame(client, data) do 134 | send_frame(client, 0x8, 0x9, data) 135 | end 136 | 137 | def send_pong_frame(client, data) do 138 | send_frame(client, 0x8, 0xA, data) 139 | end 140 | 141 | defp send_frame(client, flags, opcode, data) do 142 | mask = :rand.uniform(1_000_000) 143 | masked_data = Frame.mask(data, mask) 144 | 145 | mask_flag_and_size = 146 | case byte_size(masked_data) do 147 | size when size <= 125 -> <<1::1, size::7>> 148 | size when size <= 65_535 -> <<1::1, 126::7, size::16>> 149 | size -> <<1::1, 127::7, size::64>> 150 | end 151 | 152 | Transport.send(client, [ 153 | <>, 154 | mask_flag_and_size, 155 | <>, 156 | masked_data 157 | ]) 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /test/bandit/test/support/telemetry_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule Bandit.TelemetryCollector do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | def start_link(event_names) do 7 | GenServer.start_link(__MODULE__, event_names) 8 | end 9 | 10 | def record_event(event, measurements, metadata, pid) do 11 | GenServer.cast(pid, {:event, event, measurements, metadata}) 12 | end 13 | 14 | def get_events(pid) do 15 | GenServer.call(pid, :get_events) 16 | end 17 | 18 | def init(event_names) do 19 | # Use __MODULE__ here to keep telemetry from warning about passing a local capture 20 | # https://hexdocs.pm/telemetry/telemetry.html#attach/4 21 | :telemetry.attach_many( 22 | "#{inspect(self())}.trace", 23 | event_names, 24 | &__MODULE__.record_event/4, 25 | self() 26 | ) 27 | 28 | {:ok, []} 29 | end 30 | 31 | def handle_cast({:event, event, measurements, metadata}, events) do 32 | {:noreply, [{event, measurements, metadata} | events]} 33 | end 34 | 35 | def handle_call(:get_events, _from, events) do 36 | {:reply, Enum.reverse(events), events} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/bandit/test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TestHelpers do 2 | @moduledoc false 3 | 4 | @regex ~r/(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:[0-2][0-9]|3[0-1]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} (?:[0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT/ 5 | 6 | def valid_date_header?(date_header) do 7 | Regex.match?(@regex, date_header) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/bandit/test/support/transport.ex: -------------------------------------------------------------------------------- 1 | defmodule Transport do 2 | @moduledoc false 3 | 4 | def tcp_client(context) do 5 | {:ok, socket} = 6 | :gen_tcp.connect(~c"localhost", context[:port], active: false, mode: :binary, nodelay: true) 7 | 8 | {:client, %{socket: socket, transport: :gen_tcp}} 9 | end 10 | 11 | def tls_client(context, protocols) do 12 | {:ok, socket} = 13 | :ssl.connect(~c"localhost", context[:port], 14 | active: false, 15 | mode: :binary, 16 | nodelay: true, 17 | verify: :verify_peer, 18 | cacertfile: Path.join(__DIR__, "../support/ca.pem"), 19 | alpn_advertised_protocols: protocols 20 | ) 21 | 22 | {:client, %{socket: socket, transport: :ssl}} 23 | end 24 | 25 | def send({:client, %{transport: transport, socket: socket}}, data) do 26 | transport.send(socket, data) 27 | end 28 | 29 | def recv({:client, %{transport: transport, socket: socket}}, length) do 30 | transport.recv(socket, length) 31 | end 32 | 33 | def close({:client, %{transport: transport, socket: socket}}) do 34 | transport.close(socket) 35 | end 36 | 37 | def sockname({:client, %{transport: :gen_tcp, socket: socket}}) do 38 | :inet.sockname(socket) 39 | end 40 | 41 | def sockname({:client, %{transport: :ssl, socket: socket}}) do 42 | :ssl.sockname(socket) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/bandit/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(exclude: :external_conformance) 2 | 3 | # Capture all logs so we're able to assert on logging done at info level in tests 4 | Logger.configure(level: :debug) 5 | Logger.configure_backend(:console, level: :warning) 6 | -------------------------------------------------------------------------------- /test/dune: -------------------------------------------------------------------------------- 1 | (executable 2 | (name http_test) 3 | (modules http_test) 4 | (preprocess 5 | (pps bytestring.ppx)) 6 | (libraries trail nomad riot x509 mirage-crypto-rng mirage-crypto-rng.unix)) 7 | -------------------------------------------------------------------------------- /test/h2spec/dune: -------------------------------------------------------------------------------- 1 | (executable 2 | (name server) 3 | (preprocess 4 | (pps bytestring.ppx)) 5 | (libraries nomad)) 6 | -------------------------------------------------------------------------------- /test/h2spec/h2spec.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /test/h2spec/run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run \ 4 | -ti --rm \ 5 | summerwind/h2spec \ 6 | -h "host.docker.internal" \ 7 | -p 2112 \ 8 | $1 \ 9 | --strict \ 10 | -j "./test/h2spec/report.$(date +%s).xml" 11 | -------------------------------------------------------------------------------- /test/h2spec/server.ml: -------------------------------------------------------------------------------- 1 | [@@@warning "-8"] 2 | 3 | open Riot 4 | open Trail 5 | 6 | module Test : Application.Intf = struct 7 | let start () = 8 | Riot.Logger.set_log_level (Some Debug); 9 | sleep 0.1; 10 | Riot.Logger.info (fun f -> f "starting nomad server"); 11 | 12 | let hello_world conn = 13 | conn |> Conn.send_response `OK {%b| "hello world" |} 14 | in 15 | 16 | let handler = Nomad.trail [ hello_world ] in 17 | 18 | Nomad.start_link ~port:2112 ~handler () 19 | end 20 | 21 | let () = Riot.start ~apps:[ (module Riot.Logger); (module Test) ] () 22 | -------------------------------------------------------------------------------- /test/h2spec/tls.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFSzCCAzOgAwIBAgIUDqxfiGDtqNRUH4jtEy/0rzWRlOkwDQYJKoZIhvcNAQEL 3 | BQAwNTESMBAGA1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJTRTESMBAGA1UEBwwJ 4 | U3RvY2tob2xtMB4XDTIzMTIyNzAyMTczN1oXDTMzMTIyNDAyMTczN1owNTESMBAG 5 | A1UEAwwJbG9jYWxob3N0MQswCQYDVQQGEwJTRTESMBAGA1UEBwwJU3RvY2tob2xt 6 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAx9Z2zhjg5SyUX85WMkhs 7 | kajgRR5bqGKPhqglbdtslxwG4lgL6tzPWAEJ0i9L2vBkdGNg27EdwFrui3VJYjRe 8 | 5T8w2h83hQ8P4IEq2LEhuhFHEeC7DSf11dESFZ/HEa9am6d3Xv0qAdg+5S1GSmjU 9 | 1eBx53o2jlQc2eEcZ8488Nf/+61MDufKZDr1yCOXzudXJO+O41h+S7I1XSVPlN7r 10 | ouRq5U+e37GoRdTnuyLIQ1XnHgXELNwG1LKDJstdsOwl6Z7o1Mtb/R3Ja+3YHwfW 11 | pDNyt0a8WAPPM6bzMFNjCml+8h1SfBX7uYEz9PTxY9WYLyy/Dg9Vwi6qnlkG8XbL 12 | yYCV4sg/IRXy32kg3mHZd10rcKbJp7Jx4nZ25teg8/s9WEIz64CfPvyapS3PIJbl 13 | nwPzsi6G2MDy1ZM6J9OlFpAAu2kjvocs9dwmiVutHEWGyJ2VN5ykV5NgPzd0p+x+ 14 | C4kQGie/engXwgM7Xz20zmJk4Atfe7r8FTxD6lwuGGdSG4nVZy+ugodXWLUq7ROi 15 | DM4cxTgq1ZEydoXTvCQ6Vds5mcA/hBZjggkXpyqRuUFKTMsL+FF9nMRtA2IwKXlg 16 | 5VNiXNS+p2oW7z75SOoKJtK5G7EwOMxv3OCdSqCSK1Qd4Rz4a6E9McxbmV99J838 17 | RKOwocMXiv6VMsVC6+tuyfUCAwEAAaNTMFEwHQYDVR0OBBYEFOneKxyRtEPYqcc1 18 | iCD+bCZmXg0/MB8GA1UdIwQYMBaAFOneKxyRtEPYqcc1iCD+bCZmXg0/MA8GA1Ud 19 | EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAJ/mACwlugqWYMjLO2JjUGe0 20 | NMTCrvelfZI+GIJXZJj5zWHNng5XpcJ+kTtG5nm763RbWs6Whl7fqScel06fwoD7 21 | oYQ1GOE4boCwo0rA8cGtxKOAmkAx+LPuo+Id2pYQTT9SR6GvngaBhvY6gbbaWOkp 22 | jk6vlrUGXd0lP7TtwH8j1ijewKQBpEEAuZOP17R9r9R6j9e3qvrqIMP9s6Cr0wLW 23 | PMQaPPHV0priuQyMq1B4EIAZKqXVL4E6r+YW/hOl3MiU7XTyxjY2xfT50S7Q/Es5 24 | Hl7c9dZDGzs8CnObOPqRUTGkQjKGWpDajQp1+h44wqwoIEgHrmKQArK5yrAgYqhj 25 | MP3hy1MqYaIPkHBn1q7p6SJVdp3WWVhU6MhwgcKFF5W13MBOvunhqscVq1nhbMoq 26 | kGWYNFoP/Ocbjfex1pnxPTxZWcXNWVa/jHBorwiOPYw2vcktz2CJlxKpAVrDrJzE 27 | Tr0shvWuqDqqZdlLmTH61VMPCj+H0cJnqmaDPf1utq6nD8oAyrVocI88brIeloWg 28 | Kjla/4Nt1pqlg47hmgWzS8a4WFsUMICWWOcqDiPh6qFQlqyt0TE5kjlK1odUrfG9 29 | /oaMUlOgg7VI4zkAetp4mqjHdQ9EQsgr7gCeaohFO27pgiOwf9fC58Td9g01Q9Mr 30 | KXM1CjcPdn+GO3MMPZFe 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /test/h2spec/tls.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDH1nbOGODlLJRf 3 | zlYySGyRqOBFHluoYo+GqCVt22yXHAbiWAvq3M9YAQnSL0va8GR0Y2DbsR3AWu6L 4 | dUliNF7lPzDaHzeFDw/ggSrYsSG6EUcR4LsNJ/XV0RIVn8cRr1qbp3de/SoB2D7l 5 | LUZKaNTV4HHnejaOVBzZ4Rxnzjzw1//7rUwO58pkOvXII5fO51ck747jWH5LsjVd 6 | JU+U3uui5GrlT57fsahF1Oe7IshDVeceBcQs3AbUsoMmy12w7CXpnujUy1v9Hclr 7 | 7dgfB9akM3K3RrxYA88zpvMwU2MKaX7yHVJ8Ffu5gTP09PFj1ZgvLL8OD1XCLqqe 8 | WQbxdsvJgJXiyD8hFfLfaSDeYdl3XStwpsmnsnHidnbm16Dz+z1YQjPrgJ8+/Jql 9 | Lc8gluWfA/OyLobYwPLVkzon06UWkAC7aSO+hyz13CaJW60cRYbInZU3nKRXk2A/ 10 | N3Sn7H4LiRAaJ796eBfCAztfPbTOYmTgC197uvwVPEPqXC4YZ1IbidVnL66Ch1dY 11 | tSrtE6IMzhzFOCrVkTJ2hdO8JDpV2zmZwD+EFmOCCRenKpG5QUpMywv4UX2cxG0D 12 | YjApeWDlU2Jc1L6nahbvPvlI6gom0rkbsTA4zG/c4J1KoJIrVB3hHPhroT0xzFuZ 13 | X30nzfxEo7ChwxeK/pUyxULr627J9QIDAQABAoICAAW8Hx0zhBK3mIt2T61yPCFi 14 | /AqnwCAhMfa+kRJpw2BDxuPMfI0RKKchIn/EaTQfhXZ8mp07ZDvusB1S8JfvolCI 15 | Y3XDAxQftkguVMUysiHVmJlH/n42aRzpetAhjXQxuNMyN2ADumaips1rYvLENuVr 16 | Y0FuFa44djp/dhH5jnCn9jnqA36DAuEk+wQzF0pyA6N094AJPFieRN9HMJU4X4FF 17 | dlbd1dScE9TrMvpBGYerKa6IIlTaPJzygYaFvArVgBIIBC0FJ/7n0a21/eeIEU4V 18 | huOBFWseMt5L2ntGzVcRZ3n5wvH6LIbqkQvk0p+Xk944tcPox0CDF9Ti/6rCyr7Q 19 | 3jaJYi4q2VaA8uWKs6FOOrvcOBS6iwABMb9lOxhWud46g2lgcyNjZuNlcdNmLr4r 20 | mppKZ2aE9md4cNYIPCCFdjxxP0TisJXb1PF0+hYnv0UCY00IGRxOwysN66h82kzS 21 | Os5HYgIux74AWxwkm+3EEM+zdlof+i61uRi6d+MAsm+WvQnXiy2PCvBoLNdAOaA6 22 | tbCDFXL3/RJdF9wx2julyEihF0jjBafxQwZKqFlPbCwZc08kDSjozCMwork9CSA8 23 | T5pSMxS7aq6zHrFoWqypB+8QH16YpkmwzDQKkVhc97Va6kZMGk697JscUpG5bPXp 24 | VzRH+k4q0E8C0QX5eRV5AoIBAQDxXhpS8DZ7bXPGCjldYF1FO9S4pT7li/XPkXwS 25 | EFwJhci0FM4kB/B5F8gMiXhUZP7iqDriLEXRdlaeZHBGO3PcjRltfauqXBKQ1sSk 26 | C775v52xaIHjFtZ867wqHlBs+uh836fK55FjAqaEwyjZTBi/i9wB6IAltZjOFZ7N 27 | avmqLFgPAe2k9bkZiYsSImsfI3ZFT9VQewjLZgMd9gk7uXaQ8Vg1FWOU4e0Jp/L5 28 | s++sdyIw3JJCiHXULAsVZONKb5dIIbLZ2w66jhLRFkn70pjH4Wbcmd0GHvt2jKV3 29 | 1LLPZUO09Q/9RvMhL5WH/KQOmPERxeznRHuVVv8qPanORpB9AoIBAQDT89cT2pB9 30 | qfQfTssvQhN6Gq12Kf9LLr1tYwozTz+YZZdUdRY8BfT6XuwxirB1qaaDq1+/0nfv 31 | ZQZprsHr7Gh3HgdkQlESuMnrepsK7Wa17LNkL8iOfJrMAMUxPjGWnN+Hsnhk5zbv 32 | dOUIyD+vtJO+d/8znpSieIRn8iN2cyfqT/RjxWjssEQBWpaHRBGDq9GY1tfpU01O 33 | /3r4w9Tog2Pu2xGukP/l/Ohp/E3otK9O3RmSM2bTd9Qm3SomN4mX8MHr1PG9ci/p 34 | KEVeE+z9p62pTYB8diMl2d5xMQdUcuN/O5Ue1Umwmz0JqDdNqQCz5SKYZo4tT+SN 35 | a0ehr3/pOJDZAoIBAQDi89/+sn4YKr+crIpqAa1R50NK554vix30MdEezyErlw80 36 | PQfkG08DHdht6Wkqudhs2VCc0JJJtWMXBkwHzelQraAGMw+SXYbbiAZYVe8ZuRIW 37 | +bSACj5eMe65D84B2x92I3sLsBglqB1ZYoRrZkEzAtg5Nxwf2RQ4W135uyfM2mtm 38 | mSKSZLbKi2koARMGsXqJC9sBFN8dGeu+ZVUjQm15NmYBa/45xQH0fWZbYtTvLwoI 39 | Na6VPujEOzGkyTtrB2iRW5ZngLHluqd40ON6FPixoYDt1wNbuRAr1W3VMjt8BbTX 40 | V0LUnb0JLEwHFQhR7X9nfdsXTm6B6s59MoQTQIilAoIBAGbZupKdyuPP5vCSUbKb 41 | A8yKyYW/l2yqP62nE7oWSKvxEGAheSqjUV91VHQt8rcGHhFixdHVlfGLOnNqJBwR 42 | 2heDcN7L9394QDOOiVHiJac+N0b0kQPjn1JDRW1B2tpVQXsdtaJxOI02UjXSxmTC 43 | 4bbZj/NCjqnQhZ/TNjYyZzoillsb3nCMkFN/2+/DriQQ6mKaTqegjrE49Dlm/hfe 44 | Ok4b7BajsimucjGMB1pW44MHc3MokksnqME7LUriRFiAsfl4md3uXSVtL0wZqzTj 45 | ezferey3fxLNCE4xFnd6UL7a8N/HbDzQ9+uJv1xmGDszg3gku/VtAWFGn7nr6cwI 46 | cPECggEBANoHlBJofeYArwgzZdkA7kCyspdcR1Kmk6W6pFVSWMTA5S3ow8FYaxd4 47 | MxouBMoro8QhkdszwnCyvwOxghykTLT9W5jdRGSBCnHYg7GZImVWhFxKSBIc7vHb 48 | T0xHfLkAD8GYhux9mgLTVZDijB/Gj3//E26kT6eFpJR0s5Xj0kSGcIChHBoGJTmW 49 | dyWarF/v/wpjMiGHF1HQ1I9jF70Ly4WQhDcVlCLIpnM6ChNqdSJDwNDEnL7Gexjq 50 | +bRaTqSkUU++H+E78kcw+U0hC+Ac5wy2B32H5E32ghhWkVYDGlLn5oBsZcbCmqAE 51 | 7/kQTUM7p6ZquKZ2RYCmBlpyKZ7iP38= 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /test/http_test.ml: -------------------------------------------------------------------------------- 1 | [@@@warning "-8"] 2 | 3 | open Riot 4 | open Trail 5 | 6 | open Riot.Logger.Make (struct 7 | let namespace = [ "nomad"; "http_test" ] 8 | end) 9 | 10 | module Test : Application.Intf = struct 11 | let start () = 12 | set_log_level (Some Info); 13 | sleep 0.1; 14 | info (fun f -> f "starting nomad server"); 15 | 16 | let hello_world (conn : Conn.t) = 17 | debug (fun f -> f "http_test.path: %S" (String.concat "." conn.req.path)); 18 | match conn.req.path with 19 | | [] -> conn |> Conn.send_response `OK {%b|"hello world"|} 20 | | [ "echo_method" ] -> 21 | let body = 22 | conn.req.meth |> Http.Method.to_string |> Bytestring.of_string 23 | in 24 | conn |> Conn.send_response `OK body 25 | | [ "*" ] when conn.req.meth = `OPTIONS -> 26 | let scheme = 27 | Uri.scheme conn.req.uri |> Option.value ~default:"no-scheme" 28 | in 29 | let host = Uri.host_with_default ~default:"no-host" conn.req.uri in 30 | let port = Uri.port conn.req.uri |> Option.value ~default:0 in 31 | let path = 32 | conn.req.path |> List.map (Printf.sprintf "%S") |> String.concat "," 33 | in 34 | let query = Uri.query conn.req.uri |> Uri.encoded_of_query in 35 | let body = 36 | Format.sprintf 37 | {|{ 38 | "scheme": "%s", 39 | "host": "%s", 40 | "port": %d, 41 | "path_info": [%s], 42 | "query_string": "%s" 43 | }|} 44 | scheme host port path query 45 | |> Bytestring.of_string 46 | in 47 | conn |> Conn.send_response `OK body 48 | | [ "peer_data" ] -> 49 | let Conn.{ ip; port } = conn.peer in 50 | let body = 51 | Format.sprintf {|{ "address": "%s", "port": %d }|} 52 | (Net.Addr.to_string ip) port 53 | |> Bytestring.of_string 54 | in 55 | conn |> Conn.send_response `OK body 56 | | [ "echo_components" ] -> 57 | let scheme = 58 | Uri.scheme conn.req.uri |> Option.value ~default:"no-scheme" 59 | in 60 | let host = Uri.host_with_default ~default:"no-host" conn.req.uri in 61 | let port = Uri.port conn.req.uri |> Option.value ~default:0 in 62 | let path = 63 | conn.req.path |> List.map (Printf.sprintf "%S") |> String.concat "," 64 | in 65 | let query = Uri.query conn.req.uri |> Uri.encoded_of_query in 66 | let body = 67 | Format.sprintf 68 | {|{ 69 | "scheme": "%s", 70 | "host": "%s", 71 | "port": %d, 72 | "path_info": [%s], 73 | "query_string": "%s" 74 | }|} 75 | scheme host port path query 76 | |> Bytestring.of_string 77 | in 78 | conn |> Conn.send_response `OK body 79 | | [ "send_big_body" ] -> 80 | let body = String.make 10_000 'a' |> Bytestring.of_string in 81 | conn |> Conn.send_response `OK body 82 | | [ "send_content_encoding" ] -> 83 | conn 84 | |> Conn.with_header "content-encoding" "deflate" 85 | |> Conn.send_response `OK 86 | (String.make 10_000 'a' |> Bytestring.of_string) 87 | | [ "send_strong_etag" ] -> 88 | conn 89 | |> Conn.with_header "etag" "\"1234\"" 90 | |> Conn.send_response `OK 91 | (String.make 10_000 'a' |> Bytestring.of_string) 92 | | [ "send_weak_etag" ] -> 93 | conn 94 | |> Conn.with_header "etag" "W/\"1234\"" 95 | |> Conn.send_response `OK 96 | (String.make 10_000 'a' |> Bytestring.of_string) 97 | | [ "send_no_transform" ] -> 98 | conn 99 | |> Conn.with_header "cache-control" "no-transform" 100 | |> Conn.send_response `OK 101 | (String.make 10_000 'a' |> Bytestring.of_string) 102 | | [ "send_incorrect_content_length" ] -> 103 | conn 104 | |> Conn.with_header "content-length" "10001" 105 | |> Conn.send_response `OK 106 | (String.make 10_000 'a' |> Bytestring.of_string) 107 | | [ "send_200" ] -> conn |> Conn.send_status `OK 108 | | [ "send_204" ] -> 109 | conn |> Conn.send_response `No_content {%b|"bad content"|} 110 | | [ "send_301" ] -> conn |> Conn.send_status `Moved_permanently 111 | | [ "send_304" ] -> 112 | conn |> Conn.send_response `Not_modified {%b|"bad content"|} 113 | | [ "send_401" ] -> conn |> Conn.send_status `Unauthorized 114 | | [ "send_stream" ] -> 115 | let chunks = Seq.repeat {%b|"hello world"|} in 116 | 117 | chunks 118 | |> Seq.fold_left 119 | (fun conn chunk -> Conn.chunk chunk conn) 120 | (Conn.send_chunked `OK conn) 121 | |> Conn.close 122 | | [ "send_chunked_200" ] -> 123 | conn |> Conn.send_chunked `OK |> Conn.chunk {%b|"OK"|} |> Conn.close 124 | | [ "erroring_chunk" ] -> 125 | let conn = conn |> Conn.send_chunked `OK |> Conn.chunk {%b|"OK"|} in 126 | Atacama.Connection.close conn.conn; 127 | conn |> Conn.chunk {%b|"NOT OK"|} 128 | | [ "send_file" ] -> 129 | let query = Uri.query conn.req.uri in 130 | debug (fun f -> f "%S" (Uri.encoded_of_query query)); 131 | let off = List.assoc "offset" query |> List.hd |> int_of_string in 132 | let len = List.assoc "length" query |> List.hd |> int_of_string in 133 | conn 134 | |> Conn.send_file ~off ~len `OK 135 | ~path:"./test/bandit/test/support/sendfile" 136 | | [ "send_full_file" ] -> 137 | conn |> Conn.send_file `OK ~path:"./test/bandit/test/support/sendfile" 138 | | [ "send_full_file_204" ] -> 139 | conn 140 | |> Conn.send_file `No_content 141 | ~path:"./test/bandit/test/support/sendfile" 142 | | [ "send_full_file_304" ] -> 143 | conn 144 | |> Conn.send_file `Not_modified 145 | ~path:"./test/bandit/test/support/sendfile" 146 | | [ "send_inform" ] -> 147 | conn 148 | |> Conn.inform `Continue [ ("x-from", "inform") ] 149 | |> Conn.send_response `OK {%b|"Informer"|} 150 | | [ "report_version" ] -> 151 | let body = 152 | conn.req.version |> Http.Version.to_string |> Bytestring.of_string 153 | in 154 | conn |> Conn.send_response `OK body 155 | | "expect_headers" :: _ -> conn |> Conn.send_response `OK {%b|"OK"|} 156 | | "expect_no_body" :: [] -> 157 | let[@warning "-8"] (Conn.Ok (conn, body)) = Conn.read_body conn in 158 | assert (Bytestring.to_string body = ""); 159 | conn |> Conn.send_response `OK {%b|"OK"|} 160 | | "expect_body" :: [] -> 161 | let expected_content_length = "8000000" in 162 | let content_length = 163 | Http.Header.get conn.req.headers "content-length" |> Option.get 164 | in 165 | debug (fun f -> f "content_length: %s" content_length); 166 | let expected_body = 167 | List.init 800000 (fun _ -> "0123456789") |> String.concat "" 168 | in 169 | let[@warning "-8"] (Conn.Ok (conn, actual_body)) = 170 | Conn.read_body conn 171 | in 172 | let actual_body = Bytestring.to_string actual_body in 173 | debug (fun f -> f "actual_ %d" (String.length actual_body)); 174 | assert (String.equal content_length expected_content_length); 175 | assert (String.equal actual_body expected_body); 176 | conn |> Conn.send_response `OK {%b|"OK"|} 177 | | "expect_body_with_multiple_content_length" :: [] -> 178 | let expected_content_length = "8000000,8000000,8000000" in 179 | let content_length = 180 | Http.Header.get conn.req.headers "content-length" |> Option.get 181 | in 182 | debug (fun f -> f "content_length: %s" content_length); 183 | let expected_body = 184 | List.init 8_000_000 (fun _ -> "a") |> String.concat "" 185 | in 186 | let[@warning "-8"] (Conn.Ok (conn, actual_body)) = 187 | Conn.read_body conn 188 | in 189 | let actual_body = Bytestring.to_string actual_body in 190 | debug (fun f -> f "actual_ %d" (String.length actual_body)); 191 | assert (String.equal content_length expected_content_length); 192 | assert (String.equal actual_body expected_body); 193 | conn |> Conn.send_response `OK {%b|"OK"|} 194 | | "read_one_byte_at_a_time" :: [] -> 195 | let[@warning "-8"] (Conn.Ok (conn, body)) = 196 | Conn.read_body ~limit:5 conn 197 | in 198 | conn |> Conn.send_response `OK body 199 | | "error_catcher" :: [] -> 200 | let[@warning "-8"] (Conn.Error (conn, reason)) = 201 | Conn.read_body conn 202 | in 203 | let body = 204 | (match reason with 205 | | `Excess_body_read -> "Excess_body_read" 206 | | (`Closed | `Process_down | `Timeout | `Unix_error _) as reason -> 207 | Format.asprintf "%a" IO.pp_err reason) 208 | |> Bytestring.of_string 209 | in 210 | conn |> Conn.send_response `OK body 211 | | "multiple_body_read" :: [] -> 212 | debug (fun f -> f "multiple_body_read"); 213 | debug (fun f -> f "multiple_body_read: %d" conn.req.body_remaining); 214 | let[@warning "-8"] (Conn.Ok (conn, body)) = Conn.read_body conn in 215 | debug (fun f -> f "multiple_body_read: %d" conn.req.body_remaining); 216 | let[@warning "-8"] (Conn.Error (conn, _reason)) = 217 | Conn.read_body conn 218 | in 219 | debug (fun f -> f "multiple_body_read: %d" conn.req.body_remaining); 220 | conn |> Conn.send_response `OK body 221 | | "expect_chunked_body" :: [] -> 222 | let transfer_encoding = 223 | Http.Header.get conn.req.headers "transfer-encoding" |> Option.get 224 | in 225 | let[@warning "-8"] (Conn.Ok (conn, actual_body)) = 226 | Conn.read_body conn 227 | in 228 | let actual_body = Bytestring.to_string actual_body in 229 | let expected_body = 230 | List.init 8_000_000 (fun _ -> "a") |> String.concat "" 231 | in 232 | debug (fun f -> f "actual_ %d" (String.length actual_body)); 233 | assert (String.equal transfer_encoding "chunked"); 234 | assert (String.equal actual_body expected_body); 235 | conn |> Conn.send_response `OK {%b|"OK"|} 236 | | [ "upgrade_websocket" ] -> conn |> Conn.upgrade (Obj.magic false) 237 | (* this is a confusing test, but the goal is to check if we fail to 238 | upgrade, we will return a 500 *) 239 | | [ "upgrade_unsupported" ] -> 240 | conn 241 | |> Conn.upgrade (Obj.magic false) 242 | |> Conn.send_response `OK {%b|"Not supported"|} 243 | | [ "date_header" ] -> 244 | conn 245 | |> Conn.with_header "date" "Tue, 27 Sep 2022 07:17:32 GMT" 246 | |> Conn.send_response `OK {%b|"OK"|} 247 | | _ -> failwith "not implemented" 248 | in 249 | 250 | let handler = 251 | Nomad.trail 252 | Trail.[ use (module Logger) Logger.(args ~level:Debug ()); hello_world ] 253 | in 254 | 255 | Runtime.Stats.start ~every:2_000_000L (); 256 | Nomad.start_link 257 | ~transport: 258 | Atacama.Transport.( 259 | tcp 260 | ~config:{ receive_timeout = 5_000_000L; send_timeout = 1_000_000L } 261 | ()) 262 | ~config: 263 | (Nomad.Config.make ~max_header_count:40 ~max_header_length:5000 ()) 264 | ~port:2114 ~handler () 265 | end 266 | 267 | let () = Riot.start ~apps:[ (module Riot.Logger); (module Test) ] () 268 | --------------------------------------------------------------------------------