diff --git a/.gitignore b/.gitignore index 42f451d..c4d762b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,11 @@ gradle.properties .vscode .DS_Store fileserver/ +gradlew.bat +gradle/ +gradle/ +/target/ +.classpath +.factorypath +*.prefs +.project diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e8542 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use OpenJDK 23 +FROM openjdk:23-slim + +# Set working directory +WORKDIR /app + +# Install curl +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Copy the application files +COPY build/libs/httpserver.jar . +COPY build/libs/httpserver-test.jar . + +RUN mkdir -p /app/fileserver + +# Expose the port your application runs on +EXPOSE 8080 + +# Command to run the application +# Replace "YourApplication.jar" with your actual JAR file name +CMD ["java", "-cp", "httpserver-test.jar:httpserver.jar", "SimpleFileServer","fileserver","8080","fileserver/logfile.txt" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE index 6912f5e..59b3934 100644 --- a/LICENSE +++ b/LICENSE @@ -1,342 +1,24 @@ NOTE: The source code in this project is covered under multiple licenses. The licenses are contained in the source files. This license applies where applicable to any solely original code. - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index ecd6781..27f4b6a 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,142 @@ -# httpserver - -A zero-dependency implementation of the JDK com.sun.net.httpserver.HttpServer specification with a few significant enhancements. -It adds websocket support using modified source from nanohttpd. +# httpserver -It has basic server-side proxy support using [ProxyHandler](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/extras/ProxyHandler.java). +Zero-dependency implementation of the JDK `com.sun.net.httpserver.HttpServer` [specification](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/com/sun/net/httpserver/package-summary.html) with a few significant enhancements. -ProxyHandler also supports tunneling proxies using CONNECT for https. +- WebSocket support using modified source code from nanohttpd. +- Server-side proxy support using [ProxyHandler](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/extras/ProxyHandler.java). (Tunneling proxies are also supported using CONNECT for https.) +- HTTP/2 [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) support +- Performance enhancements such as proper HTTP pipelining, optimized String parsing, etc. All async functionality has been removed. All synchronized blocks were removed in favor of other Java concurrency concepts. The end result is an implementation that easily integrates with Virtual Threads available in JDK 21 - simply set a virtual thread based ExecutorService. -Improved performance by more than **10x** over the JDK implementation, using http pipelining, optimized String parsing, etc. +Improves performance by more than **10x** over the JDK implementation. -Designed for embedding with only a 90kb jar and zero dependencies. +Designed for embedding with only a 200kb jar and zero dependencies. ## background -The JDK httpserver implementation has no support for connection upgrades, so it is not possible to add websocket support. +The built-in JDK httpserver implementation has no support for connection upgrades, so it is not possible to add websocket support. Additionally, the code still has a lot of async - e.g. using SSLEngine to provide SSL support - which makes it more difficult to understand and enhance. -The streams based processing and thread per connection design simplifies the code substantially. +The thread-per-connection synchronous design simplifies the code substantially. + +## testing/compliance + +Nearly all tests from the JDK are included, so this version should be highly compliant and reliable. -## testing +Additional proxy and websockets tests are included. -Nearly all of the tests were included from the JDK so this version should be highly compliant and reliable. +The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) + +## maven +[![Maven Central](https://img.shields.io/maven-central/v/io.github.robaho/httpserver.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.github.robaho/httpserver) + +```xml + + io.github.robaho + httpserver + use version from badge above without leading v + +``` ## using -Set the default HttpServer provider when starting the jvm: +The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` instead of the JDK implementation when the jar is placed on the class/module path. If there are multiple `HttpServer` providers on the classpath, the `com.sun.net.httpserver.HttpServerProvider` system property can be used to specify the correct one: --Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider +Eg. -Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider -or instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). +Alternatively, you can instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). -or the service loader will automatically find it when the jar is placed on the class path when using the standard HttpServer service provider. +### Example Usage +```java +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; -## performance +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +public class Test { + + public static void main(String[] args) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0); + server.createContext("/", new MyHandler()); + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // sets virtual thread executor + server.start(); + } + + static class MyHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + String response = "This is the response"; + byte[] bytes = response.getBytes(); + + // -1 means no content, 0 means unknown content length + var contentLength = bytes.length == 0 ? -1 : bytes.length; + + try (OutputStream os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(200, contentLength); + os.write(bytes); + } + } + } +} + +``` +There is a [simple file server](https://github.com/robaho/httpserver/blob/72775986b38120b30dc4bc0438d21136ff8ec192/src/test/extras/SimpleFileServer.java#L48) that can be used to for basic testing. It has download, echo, and "hello" capabilities. Use + +``` +gradle runSimpleFileServer +``` + +## websockets + +For websocket usage, see the examples in the [websocket testing folder](https://github.com/robaho/httpserver/tree/main/src/test/java/robaho/net/httpserver/websockets). + +In general, create a handler that extends WebSocketHandler, and add an endpoint for the handler: + +``` + HttpHandler h = new EchoWebSocketHandler(); + HttpContext c = server.createContext("/ws", h); +``` + +The low-level websocket api is [nanohttpd](https://github.com/NanoHttpd/nanohttpd) so there are many examples on the web. + +## logging + +All logging is performed using the [Java System Logger](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/System.Logger.html) + +## enable Http2 + +Http2 support is enabled via Java system properties. + +Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. + +Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. -** updated 11/22/2024: retested using JDK 23. The results for the JDK version dropped dramatically because I was able to resolve the source of the errors (incorrect network configuration) - and now the robaho version is more than 10x faster. +See the additional Http2 options in `ServerConfig.java` -This version performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 +## performance + +This version performs more than **10x** faster than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go-wrk)2 -1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
+1_The robaho version has been [submitted](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver-robaho) to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ +Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 3x, and http1 by 5x. + +The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) + +
+ vs JDK performance details + **robaho tech empower** ``` robertengels@macmini go-wrk % wrk -H 'Host: imac' -H 'Accept: text/plain,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7' -H 'Connection: keep-alive' --latency -d 60 -c 64 --timeout 8 -t 2 http://imac:8080/plaintext -s ~/pipeline.lua -- 16 @@ -133,8 +221,87 @@ Number of Errors: 0 99.99999%: 1.83ms stddev: 174.373ms +``` +
+
+ vs Jetty performance details + +The server is an iMac 4ghz quad-core i7 running OSX 13.7.2. JVM used is JDK 23.0.1. The `h2load` client was connected via a 20Gbs lightening network from an M1 Mac Mini. + +Using `h2load -n 1000000 -m 1000 -c 16 [--h1] http://imac:` + +Jetty jetty-11.0.24 +Javalin version 6.4.0 + +Jetty 11 http2 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: h2c +finished in 3.47s, 288284.80 req/s, 10.17MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 35.29MB (37002689) total, 7.63MB (8001809) headers (space savings 90.12%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 94us 381.85ms 6.42ms 21.51ms 96.90% +time for connect: 389us 5.88ms 3.15ms 1.75ms 62.50% +time to 1st byte: 6.61ms 11.74ms 7.85ms 1.24ms 87.50% +req/s : 18020.94 23235.01 19829.09 1588.94 75.00% + +``` + +Jetty 11 http1 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: http/1.1 +finished in 3.63s, 275680.69 req/s, 36.02MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000021 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 130.65MB (137000000) total, 86.78MB (91000000) headers (space savings 0.00%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 1.59ms 336.00ms 53.17ms 51.56ms 85.36% +time for connect: 422us 2.57ms 1.54ms 632us 62.50% +time to 1st byte: 2.98ms 314.97ms 26.14ms 77.12ms 93.75% +req/s : 17232.15 21230.14 18780.35 1130.32 68.75 + +``` + +robaho http2 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: h2c +finished in 1.03s, 966710.36 req/s, 40.57MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 457us 71.41ms 14.71ms 8.63ms 73.09% +time for connect: 336us 5.77ms 3.13ms 1.73ms 62.50% +time to 1st byte: 6.59ms 15.30ms 10.40ms 3.32ms 50.00% +req/s : 60461.71 66800.04 62509.79 1544.65 75.00% ``` +robaho http1 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: http/1.1 +finished in 776.64ms, 1287592.88 req/s, 106.83MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000123 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 82.97MB (87000000) total, 46.73MB (49000000) headers (space savings 0.00%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 376us 380.30ms 9.12ms 32.43ms 99.20% +time for connect: 240us 2.51ms 1.50ms 720us 62.50% +time to 1st byte: 3.04ms 18.85ms 8.93ms 5.77ms 68.75% +req/s : 80530.13 167605.46 122588.82 42385.59 87.50% +``` + +
+ + ## server statistics The server tracks some basic statistics. To enable the access endpoint `/__stats`, set the system property `robaho.net.httpserver.EnableStatistics=true`. @@ -154,17 +321,29 @@ Idle Closes: 0 Reply Errors: 0 ``` -The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated from the previous statistics request. +The counts and rates for non "Total" statistics are reset with each pull of the statistics. -## maven +## performance notes -```xml - - io.github.robaho - httpserver - 1.0.10 - +Http2 performance has not been fully optimized. The http2 version is about 20-30% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. +http2 outperforms http1 when sending multiple simultaneous requests from the client with payloads, as most servers and clients do not implement http pipelining when payloads are involved. + +TODO: sending hpack headers does not use huffman encoding or dynamic table management. see the following paper https://www.mew.org/~kazu/doc/paper/hpack-2017.pdf for optimizing the implementation further. + +The most expensive operations involve converting strings to URI instances. Unfortunately, since using URI is part of the [HttpExchange API](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.httpserver/com/sun/net/httpserver/HttpExchange.html#getRequestURI()) little can be done in this regard. +It could be instantiated lazily, but almost all handlers need access to the URI components (e.g. path, query, etc.) + +The standard JDK Headers implementation normalizes all headers to be first character capitalized and the rest lowercase. To ensure optimum performance, client code should use the same format to avoid the normalization cost, i.e. + +```java +Use + +var value = request.getFirst("Content-length"); + +instead of + +var value = request.getFirst("content-length"); +var value = request.getFirst("CONTENT-LENGTH"); ``` -## future work -There is no http2 support. + diff --git a/build.gradle b/build.gradle index 8e90fe7..e47b7dc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ plugins { - id 'java-library' id 'maven-publish' id 'signing' + id 'java-library' + id 'tech.yanand.maven-central-publish' version '1.3.0' } repositories { @@ -10,7 +11,7 @@ repositories { java { toolchain { - languageVersion = JavaLanguageVersion.of(23) + languageVersion = JavaLanguageVersion.of(21) } withSourcesJar() withJavadocJar() @@ -25,6 +26,12 @@ tasks.withType(Test) { jvmArgs += "--add-opens=jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED" systemProperty("java.util.logging.config.file","logging.properties") systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") + systemProperty("robaho.net.httpserver.http2OverSSL","true") + systemProperty("robaho.net.httpserver.http2OverNonSSL","true") + // systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") + // systemProperty("robaho.net.httpserver.http2DisableFlushDelay","true") + // systemProperty("robaho.net.httpserver.http2OverSSL","true") + systemProperty("robaho.net.httpserver.http2OverNonSSL","true") // systemProperty("javax.net.debug","ssl:handshake:verbose:keymanager:trustmanager") } @@ -32,10 +39,12 @@ tasks.withType(JavaExec) { jvmArgs += "--enable-preview" systemProperty("java.util.logging.config.file","logging.properties") systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") -} - -tasks.withType(JavaExec).configureEach { - javaLauncher.set(javaToolchains.launcherFor(java.toolchain)) + // systemProperty("robaho.net.httpserver.http2OverSSL","true") + systemProperty("robaho.net.httpserver.http2OverNonSSL","true") + systemProperty("robaho.net.httpserver.http2InitialWindowSize","1024000") + systemProperty("robaho.net.httpserver.http2ConnectionWindowSize","1024000000") + systemProperty("robaho.net.httpserver.EnableStats","true") + systemProperty("robaho.net.httpserver.EnableDebug","true") } dependencies { @@ -64,14 +73,14 @@ sourceSets { test { java { srcDirs = [ - 'src/test/extras', - 'src/test/java', - 'src/test/java_default/bugs', - 'src/test/java_default/HttpExchange' + 'src/test/extras', + 'src/test/java', + 'src/test/java_default/bugs', + 'src/test/java_default/HttpExchange' ] } } - testMains { + create('testMains') { java { srcDirs = ['src/test/test_mains'] compileClasspath = test.output + main.output + configurations.testMainsCompile @@ -83,6 +92,60 @@ sourceSets { } } +// Use a lazy Provider to get the git version. This is the modern, configuration-cache-friendly approach. +def gitVersionProvider = project.providers.exec { + // 1. Describe the latest revision with a tag + commandLine = ['git', 'describe', '--tags', '--always'] + ignoreExitValue = true // Don't fail the build if git fails (e.g., no tags exist) +}.standardOutput.asText.map { it.trim() } + +// Apply the git version to your project +version = gitVersionProvider.get() + +task showGitVersion { + doLast { + println "project version is "+version + } +} + +build { +} + +jar { + manifest { + attributes( + "Implementation-Title": project.name, + "Implementation-Version": version) + } +} + +task runSingleUnitTest(type: Test) { + outputs.upToDateWhen { false } + dependsOn testClasses + filter { + includeTestsMatching 'PipeliningStallTest' + } + useTestNG() +} + +/** used for development to run a single test */ +task runSingleMainTest(type: Test) { + outputs.upToDateWhen { false } + dependsOn testMainsClasses + doLast { + def testname = "B6361557" + println jvmArgs + println systemProperties + def props = systemProperties + javaexec { + classpath sourceSets.testMains.runtimeClasspath + main testname + systemProperties = props + // debug true + } + } +} + task testMainsTest(type: Test) { dependsOn testMainsClasses doLast { @@ -102,60 +165,51 @@ task testMainsTest(type: Test) { } } -/** used for developmet to run a single test */ -task testSingleTest(type: Test) { - dependsOn testMainsClasses - doLast { - def testname = "Test1" - println jvmArgs - println systemProperties - def props = systemProperties - javaexec { - classpath sourceSets.testMains.runtimeClasspath - main testname - systemProperties = props - // debug true - } +task runSimpleFileServer(type: JavaExec) { + doFirst { + mkdir 'fileserver' } -} - -task runSimpleFileServer(type: Test) { dependsOn testClasses - doLast { - def props = systemProperties - mkdir 'fileserver' - javaexec { - classpath sourceSets.test.runtimeClasspath - main "SimpleFileServer" - systemProperties = props - args = ['fileserver','8888','fileserver/logfile.txt'] - // debug true - } + classpath sourceSets.test.runtimeClasspath + + // FIX 1: Use 'mainClass' instead of 'main' + // FIX 2: Replace "SimpleFileServer" with the FULLY QUALIFIED class name + // (e.g., if it's in a package named com.example) + mainClass = "com.example.SimpleFileServer" + + args = ['fileserver','443','fileserver/logfile.txt'] + + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(23) } } -task run(type: JavaExec) { - classpath sourceSets.testMains.runtimeClasspath - dependsOn testMainsClasses +task testJar(type: Jar) { + archiveClassifier.set("test") + from sourceSets.test.output, sourceSets.testMains.output + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -publish { +task runAllTests(type: Test) { dependsOn test dependsOn testMainsTest } +publish { + dependsOn runAllTests +} + publishing { publications { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.9" from components.java pom { name = 'HttpServer' - description = 'A zero dependency implements of the JDK httpserver designed for Virtual Threads. Includes websocket support.' + description = 'A zero dependency implementation of the JDK httpserver designed for Virtual Threads. Includes websocket and Http2 support.' signing { sign publishing.publications.maven @@ -170,13 +224,17 @@ publishing { licenses { license { - name = 'gnu v2.0' - url = 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html' + name = 'gnu v2.0 with classpath exception' + url = 'https://www.gnu.org/software/classpath/license.html' } license { name = 'nanohttpd' url = 'https://github.com/NanoHttpd/nanohttpd/blob/efb2ebf85a2b06f7c508aba9eaad5377e3a01e81/LICENSE.md' } + license { + name = 'MIT License' + url = 'https://opensource.org/licenses/MIT' + } } developers { @@ -189,14 +247,17 @@ publishing { } } } - repositories { - maven { - name = "OSSRH" - url = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" - credentials { - username = "$maven_user" - password = "$maven_password" - } - } - } +} + +mavenCentral { + def tokenString = "${maven_user}:${maven_password}" + def token = tokenString.bytes.encodeBase64().toString() + authToken = token + // Whether the upload should be automatically published or not. Use 'USER_MANAGED' if you wish to do this manually. + // This property is optional and defaults to 'AUTOMATIC'. + publishingType = 'AUTOMATIC' + // Max wait time for status API to get 'PUBLISHING' or 'PUBLISHED' status when the publishing type is 'AUTOMATIC', + // or additionally 'VALIDATED' when the publishing type is 'USER_MANAGED'. + // This property is optional and defaults to 60 seconds. + maxWait = 60 } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7f93135..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index b82aa23..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1aa94a4..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 93e3f59..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/logging.properties b/logging.properties index 8afc3dd..d273627 100644 --- a/logging.properties +++ b/logging.properties @@ -3,4 +3,4 @@ handlers = java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = ALL java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-7s] [%2$s] %5$s %6$s %n -robaho.net.level=FINEST \ No newline at end of file +robaho.net.level=INFO \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index c7c93e4..6d0ca7d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -2,6 +2,7 @@ exports robaho.net.httpserver; exports robaho.net.httpserver.extras; exports robaho.net.httpserver.websockets; + exports robaho.net.httpserver.http2; requires transitive java.logging; requires transitive java.net.http; diff --git a/src/main/java/robaho/net/httpserver/AuthFilter.java b/src/main/java/robaho/net/httpserver/AuthFilter.java index 9e40d4f..7da6bf5 100644 --- a/src/main/java/robaho/net/httpserver/AuthFilter.java +++ b/src/main/java/robaho/net/httpserver/AuthFilter.java @@ -28,6 +28,7 @@ import com.sun.net.httpserver.*; import java.io.*; +import java.security.Principal; public class AuthFilter extends Filter { @@ -61,7 +62,7 @@ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { Authenticator.Result r = authenticator.authenticate(t); if (r instanceof Authenticator.Success) { Authenticator.Success s = (Authenticator.Success) r; - ExchangeImpl e = ExchangeImpl.get(t); + PrincipalExchange e = (PrincipalExchange)t; e.setPrincipal(s.getPrincipal()); chain.doFilter(t); } else if (r instanceof Authenticator.Retry) { @@ -77,4 +78,9 @@ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { chain.doFilter(t); } } + + static interface PrincipalExchange { + public void setPrincipal(HttpPrincipal p); + public Principal getPrincipal(); + } } diff --git a/src/main/java/robaho/net/httpserver/BloomSet.java b/src/main/java/robaho/net/httpserver/BloomSet.java new file mode 100644 index 0000000..b73953a --- /dev/null +++ b/src/main/java/robaho/net/httpserver/BloomSet.java @@ -0,0 +1,25 @@ +package robaho.net.httpserver; + +/** small set designed for efficient negative contains */ +public class BloomSet { + private final int bloomHash; + private OpenAddressMap values; + private BloomSet(String... values) { + this.values = new OpenAddressMap<>(values.length*2); + int bloomHash = 0; + for(var v : values) { + bloomHash = bloomHash | v.hashCode(); + this.values.put(v,true); + } + this.bloomHash = bloomHash; + } + public static BloomSet of(String... values) { + return new BloomSet(values); + } + public boolean contains(String value) { + return (bloomHash & value.hashCode()) == value.hashCode() && Boolean.TRUE.equals(values.get(value)); + } + public Iterable values() { + return values.keys(); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java b/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java index 8f0f753..7b4c0d2 100644 --- a/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java +++ b/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java @@ -132,7 +132,9 @@ public void close() throws IOException { if (closed) { return; } - flush(); + if (count > 0) { + writeChunk(); + } try { /* write an empty chunk */ writeChunk(); diff --git a/src/main/java/robaho/net/httpserver/Code.java b/src/main/java/robaho/net/httpserver/Code.java index a32a5cc..47c46ec 100644 --- a/src/main/java/robaho/net/httpserver/Code.java +++ b/src/main/java/robaho/net/httpserver/Code.java @@ -28,6 +28,7 @@ public class Code { public static final int HTTP_CONTINUE = 100; + public static final int HTTP_SWITCHING_PROTOCOLS = 101; public static final int HTTP_OK = 200; public static final int HTTP_CREATED = 201; public static final int HTTP_ACCEPTED = 202; @@ -71,6 +72,8 @@ static String msg(int code) { return " OK"; case HTTP_CONTINUE: return " Continue"; + case HTTP_SWITCHING_PROTOCOLS: + return " Switching Protocols"; case HTTP_CREATED: return " Created"; case HTTP_ACCEPTED: diff --git a/src/main/java/robaho/net/httpserver/ExchangeImpl.java b/src/main/java/robaho/net/httpserver/ExchangeImpl.java index 956838b..7da9ca0 100644 --- a/src/main/java/robaho/net/httpserver/ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/ExchangeImpl.java @@ -40,8 +40,6 @@ import com.sun.net.httpserver.*; -import robaho.net.httpserver.websockets.WebSocketHandler; - class ExchangeImpl { Headers reqHdrs, rspHdrs; @@ -69,7 +67,8 @@ class ExchangeImpl { private static final String HEAD = "HEAD"; private static final String CONNECT = "CONNECT"; - + private static final String HEADER_CONNECTION = "Connection"; + private static final String HEADER_CONNECTION_UPGRADE = "Upgrade"; /* * streams which take care of the HTTP protocol framing * and are passed up to higher layers @@ -85,7 +84,7 @@ class ExchangeImpl { Map attributes; int rcode = -1; HttpPrincipal principal; - final boolean websocket; + boolean connectionUpgraded = false; ExchangeImpl( String m, URI u, Request req, long len, HttpConnection connection) throws IOException { @@ -97,11 +96,6 @@ class ExchangeImpl { this.method = m; this.uri = u; this.connection = connection; - this.websocket = WebSocketHandler.isWebsocketRequested(this.reqHdrs); - if (this.websocket) { - // length is indeterminate - len = -1; - } this.reqContentLen = len; /* ros only used for headers, body written directly to stream */ this.ros = req.outputStream(); @@ -135,6 +129,9 @@ private boolean isHeadRequest() { private boolean isConnectRequest() { return CONNECT.equals(getRequestMethod()); } + private boolean isUpgradeRequest() { + return HEADER_CONNECTION_UPGRADE.equalsIgnoreCase(reqHdrs.getFirst(HEADER_CONNECTION)); + } public void close() { if (closed) { @@ -170,7 +167,7 @@ public InputStream getRequestBody() { if (uis != null) { return uis; } - if (websocket || isConnectRequest()) { + if (connectionUpgraded || isConnectRequest() || isUpgradeRequest()) { // connection cannot be re-used uis = ris; } else if (reqContentLen == -1L) { @@ -232,26 +229,30 @@ public void sendResponseHeaders(int rCode, long contentLen) ros.write(statusLine.getBytes(ISO_CHARSET)); boolean noContentToSend = false; // assume there is content boolean noContentLengthHeader = false; // must not send Content-length is set - rspHdrs.set("Date", ActivityTimer.dateAndTime()); Integer bufferSize = (Integer)this.getAttribute(Attributes.SOCKET_WRITE_BUFFER); if(bufferSize!=null) { getConnection().getSocket().setOption(StandardSocketOptions.SO_SNDBUF, bufferSize); } - /* check for response type that is not allowed to send a body */ - if (rCode == 101) { - logger.log(Level.DEBUG, () -> "switching protocols"); + boolean flush = false; - if (contentLen != 0) { - String msg = "sendResponseHeaders: rCode = " + rCode - + ": forcing contentLen = 0"; - logger.log(Level.WARNING, msg); - } - contentLen = 0; - - } else if ((rCode >= 100 && rCode < 200) /* informational */ - || (rCode == 204) /* no content */ + /* check for response type that is not allowed to send a body */ + var informational = rCode >= 100 && rCode < 200; + + if (informational) { + if (rCode == 101) { + logger.log(Level.DEBUG, () -> "switching protocols"); + if (contentLen != 0) { + String msg = "sendResponseHeaders: rCode = " + rCode + + ": forcing contentLen = 0"; + logger.log(Level.WARNING, msg); + contentLen = 0; + } + connectionUpgraded = true; + } + noContentLengthHeader = true; // the Content-length header must not be set for interim responses as they cannot have a body + } else if ((rCode == 204) /* no content */ || (rCode == 304)) /* not modified */ { if (contentLen != -1) { @@ -263,6 +264,10 @@ public void sendResponseHeaders(int rCode, long contentLen) noContentLengthHeader = (rCode != 304); } + if(!informational) { + rspHdrs.set("Date", ActivityTimer.dateAndTime()); + } + if (isHeadRequest() || rCode == 304) { /* * HEAD requests or 304 responses should not set a content length by passing it @@ -275,13 +280,16 @@ public void sendResponseHeaders(int rCode, long contentLen) noContentToSend = true; contentLen = 0; o.setWrappedStream(new FixedLengthOutputStream(this, ros, contentLen)); - } else { /* not a HEAD request or 304 response */ + } else if(informational && !connectionUpgraded) { + // don't want to set the stream for 1xx responses, except 101, the handler must call sendResponseHeaders again with the final code + flush = true; + } else if(connectionUpgraded || isConnectRequest()) { + o.setWrappedStream(ros); + close = true; + flush = true; + } else { /* standard response with possible response data */ if (contentLen == 0) { - if (websocket || isConnectRequest()) { - o.setWrappedStream(ros); - close = true; - } - else if (http10) { + if (http10) { o.setWrappedStream(new UndefLengthOutputStream(this, ros)); close = true; } else { @@ -319,11 +327,11 @@ else if (http10) { writeHeaders(rspHdrs, ros); this.rspContentLen = contentLen; - sentHeaders = true; + sentHeaders = !informational; if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE, "Sent headers: noContentToSend=" + noContentToSend); + logger.log(Level.TRACE, "sendResponseHeaders(), code="+rCode+", noContentToSend=" + noContentToSend + ", contentLen=" + contentLen); } - if(contentLen==0) { + if(flush) { ros.flush(); } if (noContentToSend) { @@ -429,15 +437,6 @@ public HttpPrincipal getPrincipal() { void setPrincipal(HttpPrincipal principal) { this.principal = principal; } - - static ExchangeImpl get(HttpExchange t) { - if (t instanceof HttpExchangeImpl) { - return ((HttpExchangeImpl) t).getExchangeImpl(); - } else { - assert t instanceof HttpsExchangeImpl; - return ((HttpsExchangeImpl) t).getExchangeImpl(); - } - } } /** diff --git a/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java b/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java index d3f22ea..b1a64cc 100644 --- a/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java +++ b/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java @@ -39,7 +39,7 @@ class FixedLengthInputStream extends LeftOverInputStream { FixedLengthInputStream(ExchangeImpl t, InputStream src, long len) { super(t, src); if (len < 0) { - throw new IllegalArgumentException("Content-Length: " + len); + throw new IllegalArgumentException("Content-length: " + len); } this.remaining = len; } diff --git a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java index 85c00b3..a4a844f 100644 --- a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java +++ b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java @@ -44,7 +44,7 @@ class FixedLengthOutputStream extends FilterOutputStream { FixedLengthOutputStream(ExchangeImpl t, OutputStream src, long len) { super(src); if (len < 0) { - throw new IllegalArgumentException("Content-Length: " + len); + throw new IllegalArgumentException("Content-length: " + len); } this.t = t; this.remaining = len; @@ -87,12 +87,7 @@ public void close() throws IOException { throw new IOException("insufficient bytes written to stream"); } LeftOverInputStream is = t.getOriginalInputStream(); - // if after reading the rest of the known input for this request, there is - // more input available, http pipelining is in effect, so avoid flush, since - // it will be flushed after processing the next request - if(is.getRawInputStream().available()==0) { - flush(); - } + if (!is.isClosed()) { try { @@ -100,5 +95,13 @@ public void close() throws IOException { } catch (IOException e) { } } + + // if after reading the rest of the known input for this request, there is + // more input available, http pipelining is in effect, so avoid flush, since + // it will be flushed after processing the next request + if(is.getRawInputStream().available()==0) { + flush(); + } + } } diff --git a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java new file mode 100644 index 0000000..8acd76c --- /dev/null +++ b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java @@ -0,0 +1,145 @@ +package robaho.net.httpserver; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; + +import javax.net.ssl.SSLSession; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpPrincipal; +import com.sun.net.httpserver.HttpsExchange; + +import robaho.net.httpserver.http2.HTTP2Stream; + +public class Http2ExchangeImpl extends HttpsExchange implements AuthFilter.PrincipalExchange { + private final Headers request; + private final Headers response; + private final InputStream in; + private final OutputStream out; + private final URI uri; + private final String method; + private final HttpContext ctx; + protected final HTTP2Stream stream; + private HttpPrincipal principal; + private int responseCode; + + public Http2ExchangeImpl(HTTP2Stream stream, URI uri, String method, HttpContext ctx, Headers request, Headers response, InputStream in, OutputStream out) { + this.request = request; + this.response = response; + this.stream = stream; + this.in = in; + this.out = out; + this.uri = uri; + this.method = method; + this.ctx = ctx; + } + + @Override + public Headers getRequestHeaders() { + return request; + } + + @Override + public Headers getResponseHeaders() { + return response; + } + + @Override + public InputStream getRequestBody() { + return in; + } + + @Override + public OutputStream getResponseBody() { + return out; + } + + @Override + public URI getRequestURI() { + return uri; + } + + @Override + public String getRequestMethod() { + return method; + } + + @Override + public HttpContext getHttpContext() { + return ctx; + } + + @Override + public void close() { + stream.close(); + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + if(responseLength>0) { + response.set("Content-length", Long.toString(responseLength)); + } else if(responseLength==0) { + // no chunked encoding so just ignore + } else { + // -1 means no data will be sent, so should set end of stream + // response.set("Content-Length", Long.toString(responseLength)); + } + response.set(":status",Long.toString(rCode)); + responseCode = rCode; + stream.writeResponseHeaders(responseLength==-1); + } + + @Override + public InetSocketAddress getRemoteAddress() { + return stream.getRemoteAddress(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return stream.getLocalAddress(); + } + + @Override + public int getResponseCode() { + return responseCode; + } + + @Override + public String getProtocol() { + return "HTTP/2"; + } + + @Override + public Object getAttribute(String name) { + return ctx.getAttributes().get(name); + } + + @Override + public void setAttribute(String name, Object value) { + ctx.getAttributes().put(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public HttpPrincipal getPrincipal() { + return principal; + } + + @Override + public SSLSession getSSLSession() { + return stream.getSSLSession(); + } + + @Override + public void setPrincipal(HttpPrincipal p) { + principal = p; + } +} diff --git a/src/main/java/robaho/net/httpserver/HttpConnection.java b/src/main/java/robaho/net/httpserver/HttpConnection.java index 4375a8a..c74e086 100644 --- a/src/main/java/robaho/net/httpserver/HttpConnection.java +++ b/src/main/java/robaho/net/httpserver/HttpConnection.java @@ -32,7 +32,9 @@ import java.io.OutputStream; import java.lang.System.Logger; import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; import java.net.Socket; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; @@ -42,7 +44,7 @@ * one of these is hung from the selector attachment and is used to locate * everything from that. */ -class HttpConnection { +public class HttpConnection { private static final Logger logger = System.getLogger("robaho.net.httpserver"); HttpContextImpl context; @@ -57,27 +59,45 @@ class HttpConnection { volatile long lastActivityTime; volatile boolean noActivity; volatile boolean inRequest; - volatile long requestCount; + volatile long drainingAt; - SSLSession getSSLSession() { - return (socket instanceof SSLSocket ssl) ? ssl.getHandshakeSession() : null; - } + public AtomicLong requestCount = new AtomicLong(); + private final String connectionId; - @Override - public String toString() { - final var sb = new StringBuilder(HttpConnection.class.getSimpleName()); - if (socket != null) { - sb.append(" ("); - sb.append(socket); - sb.append(")"); - } - return sb.toString(); + public boolean isClosed() { + return closed; } HttpConnection(Socket socket) throws IOException { this.socket = socket; this.is = new NoSyncBufferedInputStream(new ActivityTimerInputStream(socket.getInputStream())); this.os = new NoSyncBufferedOutputStream(new ActivityTimerOutputStream(socket.getOutputStream())); + connectionId = "["+socket.getLocalPort()+"."+socket.getPort()+"]"; + } + + public void debug() { + logger.log(Level.INFO,toString()+", inRequest "+inRequest+", request count "+requestCount.get()); + } + + public SSLSession getSSLSession() { + return (socket instanceof SSLSocket ssl) ? ssl.getSession() : null; + } + + public boolean isSSL() { + return socket instanceof SSLSocket; + } + + @Override + public String toString() { + return connectionId; + } + + public InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) socket.getRemoteSocketAddress(); + } + + public InetSocketAddress getLocalAddress() { + return (InetSocketAddress) socket.getLocalSocketAddress(); } void setContext(HttpContextImpl ctx) { @@ -95,16 +115,23 @@ synchronized void close() { closed = true; if (socket != null) { - if(requestCount==0) { + if(requestCount.get()==0) { logger.log(Level.WARNING, "closing connection: remote "+socket.getRemoteSocketAddress() + " with 0 requests"); } else { - logger.log(Level.TRACE, () -> "Closing connection: remote " + socket.getRemoteSocketAddress()); + logger.log(Level.TRACE, () -> "closing connection: remote " + socket.getRemoteSocketAddress()); } } if (socket.isClosed()) { return; } + try { + if (os!=null) { + // see issue #19, flush before closing, in case of pending data + os.flush(); + } + } catch(IOException ex){} + try { /* need to ensure temporary selectors are closed */ if (is != null) { @@ -115,6 +142,7 @@ synchronized void close() { } try { if (os != null) { + os.flush(); os.close(); } } catch (IOException e) { diff --git a/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java b/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java index c7e6138..e43cd7d 100644 --- a/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java @@ -27,9 +27,10 @@ import java.io.*; import java.net.*; + import com.sun.net.httpserver.*; -class HttpExchangeImpl extends HttpExchange { +class HttpExchangeImpl extends HttpExchange implements AuthFilter.PrincipalExchange { ExchangeImpl impl; @@ -105,8 +106,8 @@ public void setStreams(InputStream i, OutputStream o) { public HttpPrincipal getPrincipal() { return impl.getPrincipal(); } - - ExchangeImpl getExchangeImpl() { - return impl; + @Override + public void setPrincipal(HttpPrincipal p) { + impl.setPrincipal(p); } } diff --git a/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java b/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java index 49b5646..71548c0 100644 --- a/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java @@ -37,7 +37,7 @@ import com.sun.net.httpserver.HttpPrincipal; import com.sun.net.httpserver.HttpsExchange; -class HttpsExchangeImpl extends HttpsExchange { +class HttpsExchangeImpl extends HttpsExchange implements AuthFilter.PrincipalExchange { ExchangeImpl impl; @@ -118,7 +118,8 @@ public HttpPrincipal getPrincipal() { return impl.getPrincipal(); } - ExchangeImpl getExchangeImpl() { - return impl; + @Override + public void setPrincipal(HttpPrincipal p) { + impl.setPrincipal(p); } } diff --git a/src/main/java/robaho/net/httpserver/LeftOverInputStream.java b/src/main/java/robaho/net/httpserver/LeftOverInputStream.java index 0ce99f3..7d5fc9c 100644 --- a/src/main/java/robaho/net/httpserver/LeftOverInputStream.java +++ b/src/main/java/robaho/net/httpserver/LeftOverInputStream.java @@ -98,20 +98,24 @@ public synchronized int read(byte[] b, int off, int len) throws IOException { * (still bytes to be read) */ public boolean drain(long l) throws IOException { - - while (l > 0) { - if (server.isFinishing()) { - break; - } - long len = readImpl(drainBuffer, 0, drainBuffer.length); - if (len == -1) { - eof = true; - return true; - } else { - l = l - len; + try { + while (l > 0) { + if (server.isFinishing()) { + break; + } + t.connection.drainingAt = ActivityTimer.now(); + long len = readImpl(drainBuffer, 0, drainBuffer.length); + if (len == -1) { + eof = true; + return true; + } else { + l = l - len; + } } + return false; + } finally { + t.connection.drainingAt = 0; } - return false; } public InputStream getRawInputStream() { return super.in; diff --git a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java index 888c1bc..b99e5aa 100644 --- a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java +++ b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java @@ -83,7 +83,7 @@ public NoSyncBufferedInputStream(InputStream in) { private void fill() throws IOException { pos = 0; count = 0; - int n = getInIfOpen().read(buf); + int n = getInIfOpen().read(getBufIfOpen()); if (n > 0) count = n; } @@ -262,6 +262,15 @@ public long transferTo(OutputStream out) throws IOException { out.write(buf,pos,count-pos); pos = count; } - return super.transferTo(out); + long total = avail; + while (true) { + fill(); + if (count <= 0) { + break; + } + out.write(buf, 0, count); + total += count; + } + return total; } } diff --git a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java new file mode 100644 index 0000000..c7326de --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java @@ -0,0 +1,145 @@ +package robaho.net.httpserver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +public class OpenAddressIntMap { + + private static class Entry { + int key; + Object value; + + Entry(int key, Object value) { + this.key = key; + this.value = value; + } + } + + private int capacity; + private int mask; + private Entry[] entries; + private int size; + private int used; + + public OpenAddressIntMap(int capacity) { + // round up to next power of 2 + capacity--; + capacity |= capacity >> 1; + capacity |= capacity >> 2; + capacity |= capacity >> 4; + capacity |= capacity >> 8; + capacity |= capacity >> 16; + capacity++; + + this.capacity = capacity; + this.mask = capacity - 1; + this.entries = new Entry[capacity]; + } + + public synchronized T put(int key, T value) { + if(used>=capacity/2) { + resize(); + } + int index = key & mask; + int start = index; + int sentinel = -1; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key==key) { + T oldValue = (T)entry.value; + entry.value = value; + if(value==null) { + size--; + } + return oldValue; + } else if (entry.value==null) { + sentinel = index; + } + index = (index + 1) & mask; + if (index == start) { + resize(); + index = key & mask; + start = index; + sentinel = -1; + } + } + if(value==null) { + return null; + } + entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); + size++; + if(sentinel!=-1) used++; + return null; + } + + private void resize() { + OpenAddressIntMap newMap = new OpenAddressIntMap(capacity << 1); + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + newMap.put(entry.key, entry.value); + } + } + this.entries = newMap.entries; + this.capacity = newMap.capacity; + this.mask = newMap.mask; + this.size = newMap.size; + this.used = newMap.used; + } + + public T get(int key) { + int index = key & mask; + int start = index; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key==key) { + return (T)entry.value; + } + index = (index + 1) & mask; + if(index==start) { + break; + } + } + return null; + } + + public T getOrDefault(int key, T defaultValue) { + T value = get(key); + return value != null ? value : defaultValue; + } + + public int size() { + return size; + } + + public void clear() { + Arrays.fill(entries, null); + size=0; + used=0; + } + + public Iterable values() { + List result = new ArrayList<>(); + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + result.add((T)entry.value); + } + } + return Collections.unmodifiableList(result); + } + + public Set> entrySet(Function valueMapper) { + Set> result = new HashSet<>(); + for (Entry entry : entries) { + if (entry != null) { + result.add(Map.entry(entry.key, valueMapper != null ? valueMapper.apply((T)entry.value) : (T2) entry.value)); + } + } + return Collections.unmodifiableSet(result); + } +} diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java new file mode 100644 index 0000000..bf2fe2d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -0,0 +1,161 @@ +package robaho.net.httpserver; + +import java.util.Arrays; +import java.util.function.BiConsumer; + +public class OpenAddressMap { + + private static class Entry { + K key; + V value; + + Entry(K key, V value) { + this.key = key; + this.value = value; + } + } + private int capacity; + private int mask; + private int size; + private int used; + private Entry[] entries; + + private static int hash(int hash) { + return hash; + // return hash ^ (hash>>>16); + } + + public OpenAddressMap(int capacity) { + // round up to next power of 2 + capacity--; + capacity |= capacity >> 1; + capacity |= capacity >> 2; + capacity |= capacity >> 4; + capacity |= capacity >> 8; + capacity |= capacity >> 16; + capacity++; + + this.capacity = capacity; + this.mask = capacity - 1; + this.entries = new Entry[capacity]; + } + + public V put(K key, V value) { + if(used>=capacity/2) { + resize(); + } + + int index = hash(key.hashCode()) & mask; + int start = index; + int sentinel = -1; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key.equals(key)) { + Object oldValue = entry.value; + entry.value = value; + if (value == null) { + size--; + } + return (V)oldValue; + } else if (entry.value == null) { + sentinel = index; + } + index = (index + 1) & mask; + if (index == start) { + resize(); + index = hash(key.hashCode()) & mask; + start = index; + } + } + entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); + size++; + if(sentinel!=-1) used++; + return null; + } + + private void resize() { + OpenAddressMap newMap = new OpenAddressMap(capacity << 1); + for (var entry : entries) { + if (entry != null) { + newMap.put(entry.key, entry.value); + } + } + this.entries = newMap.entries; + this.capacity = newMap.capacity; + this.mask = newMap.mask; + this.size = newMap.size; + this.used = newMap.used; + } + + public V get(K key) { + int index = hash(key.hashCode()) & mask; + int start = index; + // int count=0; + Entry entry; + try { + while ((entry = entries[index]) != null) { + // count++; + if (entry.key.equals(key)) { + return (V)entry.value; + } + index = (index + 1) & mask; + if(index==start) { + break; + } + } + return null; + } finally { + // System.out.println("count="+count); + // if(count>5) { + // for(var e : entries) { + // if(e!=null) { + // System.out.println(e.key+"="+e.value); + // } else { + // System.out.println("null"); + // } + // } + // } + } + } + + public int size() { + return size; + } + + public void clear() { + Arrays.fill(entries, null); + size=0; + used=0; + } + + public void forEach(BiConsumer action) { + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + action.accept((K)entry.key,(V)entry.value); + } + } + } + public Iterable keys() { + return () -> new KeyIterator(); + } + + private class KeyIterator implements java.util.Iterator { + private int index = 0; + + @Override + public boolean hasNext() { + while (index < entries.length) { + if (entries[index] != null && entries[index].value != null) { + return true; + } + index++; + } + return false; + } + + @Override + public K next() { + return (K) entries[index++].key; + } + } +} diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java new file mode 100644 index 0000000..7624d3b --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -0,0 +1,152 @@ +package robaho.net.httpserver; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.stream.Collectors; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import com.sun.net.httpserver.Headers; + +public class OptimizedHeaders extends Headers { + private final OpenAddressMap map; + public OptimizedHeaders() { + super(); + map = new OpenAddressMap(16); + } + public OptimizedHeaders(int capacity) { + super(); + map = new OpenAddressMap(capacity); + } + @Override + public int size() { + return map.size(); + } + + @Override + public boolean isEmpty() { + return map.size() == 0; + } + + @Override + public List get(Object key) { + Object o = map.get(normalize((String)key)); + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; + } + + @Override + public List put(String key, List value) { + Object o = map.put(normalize(key), value); + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; + } + + @Override + public List remove(Object key) { + Object o = map.put(normalize((String)key),null); + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; + } + + @Override + public String getFirst(String key) { + Object o = map.get(normalize(key)); + return o == null ? null : (o instanceof String s) ? s : ((List)o).getFirst(); + } + + /** + * Normalize the key by converting to following form. + * First {@code char} upper case, rest lower case. + * key is presumed to be {@code ASCII}. + */ + private static String normalize(String key) { + int len = key.length(); + if(len==0) return key; + + int i=0; + + for(;i= 'a' && c <= 'z') { + break; + } + } else { + if (c >= 'A' && c <= 'Z') { + break; + } + } + } + if(i==len) return key; + return Character.toUpperCase(key.charAt(0))+key.substring(1).toLowerCase(); + } + + @Override + public void add(String key, String value) { + var normalized = normalize(key); + Object o = map.get(normalized); + if (o == null) { + map.put(normalized, value); + } else if(o instanceof String s) { + map.put(normalized, new ArrayList(List.of(s,value))); + } else { + ((List)o).add(value); + } + } + + @Override + public void clear() { + map.clear(); + } + + @Override + public void set(String key, String value) { + map.put(normalize(key), value); + } + + @Override + public boolean containsKey(Object key) { + return map.get(normalize((String)key)) != null; + } + + @Override + public boolean containsValue(Object value) { + return entrySet().stream().anyMatch(e -> e.getValue().contains((String)value)); + } + @Override + public Set keySet() { + return entrySet().stream().map(e -> e.getKey()).collect(Collectors.toSet()); + } + + @Override + public Collection> values() { + return entrySet().stream().map(e -> e.getValue()).collect(Collectors.toSet()); + } + + @Override + public Set>> entrySet() { + Set>> set = new HashSet(); + forEach((k,v) -> set.add(new AbstractMap.SimpleEntry<>(k,v))); + return set; + } + + @Override + public boolean equals(Object o) { + return (this == o); + } + + @Override + public int hashCode() { + return map.hashCode(); + } + + @Override + public void forEach(BiConsumer> action) { + map.forEach((k,v) -> action.accept(k, (v instanceof String s) ? List.of(s) : (List)v)); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/Request.java b/src/main/java/robaho/net/httpserver/Request.java index a6967ce..d0880fc 100644 --- a/src/main/java/robaho/net/httpserver/Request.java +++ b/src/main/java/robaho/net/httpserver/Request.java @@ -182,7 +182,7 @@ Headers headers() throws IOException { if (hdrs != null) { return hdrs; } - hdrs = new Headers(); + hdrs = new OptimizedHeaders(16); BufferedBuilder key = new BufferedBuilder(32); BufferedBuilder value = new BufferedBuilder(128); @@ -221,7 +221,11 @@ else if(c==LF && prevCR) { current=value; pbs.skipWhitespace(); } else { - current.append((char)c); + if(current==key) { + key.append((char)(current.count==0 ? Character.toUpperCase(c) : Character.toLowerCase(c))); + } else { + current.append((char)c); + } } } } diff --git a/src/main/java/robaho/net/httpserver/SSLConfigurator.java b/src/main/java/robaho/net/httpserver/SSLConfigurator.java new file mode 100644 index 0000000..8cdbf6e --- /dev/null +++ b/src/main/java/robaho/net/httpserver/SSLConfigurator.java @@ -0,0 +1,48 @@ +package robaho.net.httpserver; + +import java.net.InetSocketAddress; + +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; + +class SSLConfigurator { + static void configure(SSLSocket s,HttpsConfigurator cfg) { + s.setUseClientMode(false); + if(cfg==null) return; + InetSocketAddress remoteAddress = (InetSocketAddress)s.getRemoteSocketAddress(); + Parameters params = new Parameters (cfg, remoteAddress); + cfg.configure(params); + SSLParameters sslParams = params.getSSLParameters(); + if (sslParams != null) { + s.setSSLParameters(sslParams); + } + } + static class Parameters extends HttpsParameters { + InetSocketAddress addr; + HttpsConfigurator cfg; + + Parameters (HttpsConfigurator cfg, InetSocketAddress addr) { + this.addr = addr; + this.cfg = cfg; + } + @Override + public InetSocketAddress getClientAddress () { + return addr; + } + @Override + public HttpsConfigurator getHttpsConfigurator() { + return cfg; + } + SSLParameters params; + @Override + public void setSSLParameters (SSLParameters p) { + params = p; + } + SSLParameters getSSLParameters () { + return params; + } + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/ServerConfig.java b/src/main/java/robaho/net/httpserver/ServerConfig.java index 2b5fed4..a53d653 100644 --- a/src/main/java/robaho/net/httpserver/ServerConfig.java +++ b/src/main/java/robaho/net/httpserver/ServerConfig.java @@ -33,7 +33,7 @@ */ @SuppressWarnings("removal") -class ServerConfig { +public class ServerConfig { private static final int DEFAULT_IDLE_TIMER_SCHEDULE_MILLIS = 10000; // 10 sec. @@ -50,6 +50,11 @@ class ServerConfig { private static final int DEFAULT_MAX_REQ_HEADERS = 200; private static final long DEFAULT_DRAIN_AMOUNT = 64 * 1024; + private static final int DEFAULT_HTTP2_MAX_FRAME_SIZE = 16384; + private static final int DEFAULT_HTTP2_INITIAL_WINDOW_SIZE = 65535; + private static final int DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE = 65535; + private static final int DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS = -1; // use -1 for no limit + private static long idleTimerScheduleMillis; private static long idleIntervalMillis; // The maximum number of bytes to drain from an inputstream @@ -71,6 +76,14 @@ class ServerConfig { // the value of the TCP_NODELAY socket-level option private static boolean noDelay; + private static boolean http2OverSSL; + private static boolean http2OverNonSSL; + private static int http2MaxFrameSize; + private static int http2InitialWindowSize; + private static int http2ConnectionWindowSize; + private static int http2MaxConcurrentStreams; + private static boolean http2DisableFlushDelay; + static { java.security.AccessController.doPrivileged( new PrivilegedAction() { @@ -126,6 +139,16 @@ public Void run() { noDelay = Boolean.getBoolean(pkg + ".nodelay"); + http2OverSSL = Boolean.getBoolean(pkg + ".http2OverSSL"); + http2OverNonSSL = Boolean.getBoolean(pkg + ".http2OverNonSSL"); + + http2MaxFrameSize = Integer.getInteger(pkg + ".http2MaxFrameSize", DEFAULT_HTTP2_MAX_FRAME_SIZE); + http2InitialWindowSize = Integer.getInteger(pkg + ".http2InitialWindowSize", DEFAULT_HTTP2_INITIAL_WINDOW_SIZE); + http2ConnectionWindowSize = Integer.getInteger(pkg + ".http2ConnectionWindowSize", DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE); + + http2MaxConcurrentStreams = Integer.getInteger(pkg + ".http2MaxConcurrentStreams", DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS); + http2DisableFlushDelay = Boolean.getBoolean(pkg + ".http2DisableFlushDelay"); + return null; } }); @@ -216,4 +239,34 @@ static long getReqRspTimerScheduleMillis() { static boolean noDelay() { return noDelay; } + + public static boolean http2OverSSL() { + return http2OverSSL; + } + public static boolean http2OverNonSSL() { + return http2OverNonSSL; + } + public static int http2MaxFrameSize() { + return http2MaxFrameSize; + } + public static int http2InitialWindowSize() { + return http2InitialWindowSize; + } + public static int http2ConnectionWindowSize() { + return http2ConnectionWindowSize; + } + /** + * @return the maximum number of concurrent streams per connection, or -1 for no limit + */ + public static int http2MaxConcurrentStreams() { + return http2MaxConcurrentStreams; + } + /** + * @return true if delaying flush is enabled. disabling the flush delay can improve + * latency at the expense of throughput + */ + public static boolean http2DisableFlushDelay() { + return http2DisableFlushDelay; + } + } diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 7440270..214ad23 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -50,7 +50,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.RejectedExecutionException; import java.util.logging.LogRecord; import javax.net.ssl.SSLSocket; @@ -64,6 +64,12 @@ import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; +import robaho.net.httpserver.http2.HTTP2Connection; +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.HTTP2Stats; +import robaho.net.httpserver.http2.HTTP2Stream; + /** * Provides implementation for both HTTP and HTTPS */ @@ -113,31 +119,24 @@ class ServerImpl { private Thread dispatcherThread; // statistics - private final AtomicLong connectionCount = new AtomicLong(); - private final AtomicLong requestCount = new AtomicLong(); - private final AtomicLong handleExceptionCount = new AtomicLong(); - private final AtomicLong socketExceptionCount = new AtomicLong(); - private final AtomicLong idleCloseCount = new AtomicLong(); - private final AtomicLong replyErrorCount = new AtomicLong(); - private final AtomicLong maxConnectionsExceededCount = new AtomicLong(); + private final ServerStats stats = new ServerStats(); + private final HTTP2Stats http2Stats = new HTTP2Stats(); ServerImpl(HttpServer wrapper, String protocol, InetSocketAddress addr, int backlog) throws IOException { this.protocol = protocol; this.wrapper = wrapper; + socket = new ServerSocket(); + this.logger = System.getLogger("robaho.net.httpserver."+System.identityHashCode(this)); - java.util.logging.Logger.getLogger(this.logger.getName()).setFilter(new java.util.logging.Filter(){ - @Override - public boolean isLoggable(LogRecord record) { - record.setMessage("["+protocol+":"+socket.getLocalPort()+"] "+record.getMessage()); - return true; - } + java.util.logging.Logger.getLogger(this.logger.getName()).setFilter((LogRecord record) -> { + record.setMessage("["+protocol+":"+socket.getLocalPort()+"] "+record.getMessage()); + return true; }); https = protocol.equalsIgnoreCase("https"); contexts = new ContextList(); - socket = new ServerSocket(); if (addr != null) { socket.bind(addr, backlog); bound = true; @@ -147,59 +146,46 @@ public boolean isLoggable(LogRecord record) { timer = new Timer("connection-cleaner", true); timer.schedule(new ConnectionCleanerTask(), IDLE_TIMER_TASK_SCHEDULE, IDLE_TIMER_TASK_SCHEDULE); timer.schedule(ActivityTimer.createTask(),750,750); + timer.schedule(Http2Exchange.createTask(),1000,1000); logger.log(Level.DEBUG, "HttpServer created " + protocol + " " + addr); if(Boolean.getBoolean("robaho.net.httpserver.EnableStats")) { createContext("/__stats",new StatsHandler()); } + if(Boolean.getBoolean("robaho.net.httpserver.EnableDebug")) { + createContext("/__debug",new DebugHandler()); + } } private class StatsHandler implements HttpHandler { - volatile long lastStatsTime = System.currentTimeMillis(); - volatile long lastRequestCount = 0; @Override public void handle(HttpExchange exchange) throws IOException { - - long now = System.currentTimeMillis(); - - if("reset".equals(exchange.getRequestURI().getQuery())) { - connectionCount.set(0); - requestCount.set(0); - handleExceptionCount.set(0); - socketExceptionCount.set(0); - idleCloseCount.set(0); - replyErrorCount.set(0); - maxConnectionsExceededCount.set(0); - lastStatsTime = now; - lastRequestCount = 0; - exchange.sendResponseHeaders(200,-1); - exchange.close(); - return; - } - - var rc = requestCount.get(); - var output = ( - "Connections: "+connectionCount.get()+"\n" + "Active Connections: "+allConnections.size()+"\n" + - "Requests: "+rc+"\n" + - "Requests/sec: "+(long)((rc-lastRequestCount)/(((double)(now-lastStatsTime))/1000))+"\n"+ - "Handler Exceptions: "+handleExceptionCount.get()+"\n"+ - "Socket Exceptions: "+socketExceptionCount.get()+"\n"+ - "Mac Connections Exceeded: "+maxConnectionsExceededCount.get()+"\n"+ - "Idle Closes: "+idleCloseCount.get()+"\n"+ - "Reply Errors: "+replyErrorCount.get()+"\n" + stats.stats()+ + http2Stats.stats() ).getBytes(); - lastStatsTime = now; - lastRequestCount = rc; - exchange.sendResponseHeaders(200,output.length); exchange.getResponseBody().write(output); exchange.getResponseBody().close(); } } + /** log state to assist debugging */ + private class DebugHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + logger.log(Level.INFO,"logging debug state, requestor "+exchange.getRemoteAddress()); + for(var connection : allConnections) { + connection.debug(); + } + Http2Exchange.debug(); + exchange.sendResponseHeaders(200,-1); + } + } + + public void bind(InetSocketAddress addr, int backlog) throws IOException { if (bound) { throw new BindException("HttpServer already bound"); @@ -357,53 +343,92 @@ public void run() { while (true) { try { Socket s = socket.accept(); - if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE, "accepted connection: " + s.toString()); - } - connectionCount.incrementAndGet(); - if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { - // we've hit max limit of current open connections, so we go - // ahead and close this connection without processing it + try { + executor.execute(() -> { try { - maxConnectionsExceededCount.incrementAndGet(); - logger.log(Level.WARNING, "closing accepted connection due to too many connections"); - s.close(); - } catch (IOException ignore) { + acceptConnection(s); + } catch (IOException t) { + logger.log(Level.WARNING, "unable to accept connection", t); + try { + s.close(); + } catch (IOException ex) { + } } - continue; + }); + } catch (RejectedExecutionException e) { + s.close(); } - - if (ServerConfig.noDelay()) { - s.setTcpNoDelay(true); - } - - if (https) { - // for some reason, creating an SSLServerSocket and setting the default parameters would - // not work, so upgrade to a SSLSocket after connection - SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); - SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); - sslSocket.setUseClientMode(false); - s = sslSocket; + } catch (IOException e) { + if (!isFinishing()) { + logger.log(Level.ERROR, "socket accept failed", e); } + return; + } + } + } + private void acceptConnection(Socket s) throws IOException { + if(logger.isLoggable(Level.TRACE)) { + logger.log(Level.TRACE, "accepted connection: " + s.toString()); + } + stats.connectionCount.incrementAndGet(); + if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { + // we've hit max limit of current open connections, so we go + // ahead and close this connection without processing it + try { + stats.maxConnectionsExceededCount.incrementAndGet(); + logger.log(Level.WARNING, "closing accepted connection due to too many connections"); + s.close(); + } catch (IOException ignore) { + } + return; + } - HttpConnection c = new HttpConnection(s); - try { - allConnections.add(c); + if (ServerConfig.noDelay()) { + s.setTcpNoDelay(true); + } - Exchange t = new Exchange(protocol, c); - executor.execute(t); + boolean http2 = false; - } catch (Exception e) { - logger.log(Level.TRACE, "Dispatcher Exception", e); - handleExceptionCount.incrementAndGet(); - closeConnection(c); - } - } catch (IOException e) { - if (!isFinishing()) { - logger.log(Level.ERROR, "Dispatcher Exception, terminating", e); + if (https) { + // for some reason, creating an SSLServerSocket and setting the default parameters would + // not work, so upgrade to a SSLSocket after connection + SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); + SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); + SSLConfigurator.configure(sslSocket,httpsConfig); + + sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { + if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { + return "h2"; + } else { + return "http/1.1"; } - return; + }); + // the following forces the SSL handshake to complete in order to determine the negotiated protocol + var session = sslSocket.getSession(); + if ("h2".equals(sslSocket.getApplicationProtocol())) { + logger.log(Level.DEBUG, () -> "http2 connection "+sslSocket.toString()); + http2 = true; + } else { + logger.log(Level.DEBUG, () -> "http/1.1 connection "+sslSocket.toString()); } + s = sslSocket; + } + + HttpConnection c = new HttpConnection(s); + try { + allConnections.add(c); + + if (http2) { + Http2Exchange t = new Http2Exchange(protocol, c); + t.run(); + } else { + Exchange t = new Exchange(protocol, c); + t.run(); + } + } catch (Throwable t) { + logger.log(Level.WARNING, "Dispatcher Exception", t); + stats.handleExceptionCount.incrementAndGet(); + closeConnection(c); } } } @@ -418,6 +443,174 @@ private void closeConnection(HttpConnection conn) { allConnections.remove(conn); } + /* used to link to 2 or more Filter.Chains together */ + class LinkHandler implements HttpHandler { + + Filter.Chain nextChain; + + LinkHandler(Filter.Chain nextChain) { + this.nextChain = nextChain; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + nextChain.doFilter(exchange); + } + } + + class Http2Exchange implements Runnable,HTTP2Connection.StreamHandler { + final HttpConnection connection; + final HTTP2Connection http2; + final String protocol; + + private static final Set allHttp2Exchanges = Collections.newSetFromMap(new ConcurrentHashMap<>()); + static void debug() { + for(var exchange : allHttp2Exchanges) { + exchange.http2.debug(); + } + } + + Http2Exchange(String protocol, HttpConnection conn) throws IOException { + this.connection = conn; + this.protocol = protocol; + + if(protocol.equals("https2")) { + http2Stats.sslConnections.incrementAndGet(); + } else { + http2Stats.nonsslConnections.incrementAndGet(); + } + + http2 = new HTTP2Connection(conn,http2Stats,connection.getInputStream(), connection.getOutputStream(), this); + } + + static TimerTask createTask() { + return new TimerTask() { + @Override + public void run() { + long now = System.currentTimeMillis(); + for(var exchange : allHttp2Exchanges) { + if(exchange.connection.lastActivityTime + 1000 < now) { + try { + exchange.http2.sendPing(); + } catch (IOException ex) { + } + } + } + } + }; + } + + @Override + public void run() { + allHttp2Exchanges.add(this); + + try { + if(!http2.hasProperPreface()) { + http2.sendGoAway(HTTP2ErrorCode.PROTOCOL_ERROR); + logger.log(Level.WARNING, "ServerImpl HTTP2 preface failed"); + return; + } + http2.sendMySettings(); + http2.handle(); + } catch (HTTP2Exception ex) { + logger.log(Level.WARNING, "ServerImpl http2 protocol exception "+http2,ex); + } catch (IOException ex) { + stats.socketExceptionCount.incrementAndGet(); + logger.log(Level.DEBUG, "end of stream "+http2); + } catch (Exception ex) { + logger.log(Level.WARNING, "ServerImpl unexpected exception handling http2 connection "+http2, ex); + } finally { + try { + logger.log(Level.DEBUG, "closing HTTP2 connection and streams "+http2); + closeConnection(connection); + http2.close(); + } catch (Throwable t) { + logger.log(Level.WARNING, "error closing http2 connection "+http2, t); + } + allHttp2Exchanges.remove(this); + } + } + + @Override + public Executor getExecutor() { + return ServerImpl.this.executor; + } + + @Override + public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) throws IOException { + connection.requestCount.incrementAndGet(); + stats.requestCount.incrementAndGet(); + + http2Stats.totalStreams.incrementAndGet(); + + var request = stream.getRequestHeaders(); + var response = stream.getResponseHeaders(); + + String scheme = https ? "https" : "http"; + String authority = request.getFirst(":authority"); + String path = request.getFirst(":path"); + String query = request.getFirst(":query"); + + logger.log(Level.TRACE, () -> "http2 stream started "+stream.toString()); + + if (authority == null || path == null) { + throw new IOException("Invalid HTTP/2 headers: missing :authority or :path"); + } + + request.set("Host",authority); + + URI uri; + try { + uri = new URI(scheme,authority,path,query,null); + } catch (URISyntaxException ex) { + throw new IOException("invalid uri",ex); + } + + String method = request.getFirst(":method"); + if (method == null) { + throw new IOException("Invalid HTTP/2 headers: missing :method"); + } + + // Validate Transfer-Encoding headers for HTTP/2 + List transferEncoding = request.get("Transfer-encoding"); + if (transferEncoding != null && !transferEncoding.isEmpty()) { + if (transferEncoding.size() > 1 || !transferEncoding.get(0).equalsIgnoreCase("chunked")) { + throw new IOException("Unsupported Transfer-Encoding value"); + } + } + + String uriPath = Optional.ofNullable(uri.getPath()).orElse("/"); + HttpContextImpl ctx = contexts.findContext(protocol, uriPath); + if (ctx == null) { + logger.log(Level.DEBUG, "No context found for request "+uriPath+", rejecting as not found"); + response.set(":status","404"); + stream.writeResponseHeaders(true); + out.close(); + return; + } + + logger.log(Level.TRACE,() -> "http2 request on "+connection+" "+method+" for "+uri); + + final List sf = ctx.getSystemFilters(); + final List uf = ctx.getFilters(); + + final Filter.Chain sc = new Filter.Chain(sf, ctx.getHandler()); + final Filter.Chain uc = new Filter.Chain(uf, new LinkHandler(sc)); + + try { + if (https) { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } else { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } + } catch (IOException e) { + } catch (Exception e) { + logger.log(Level.WARNING, "Dispatcher Exception on "+stream, e); + stats.handleExceptionCount.incrementAndGet(); + } + } + } + /* per exchange task */ class Exchange implements Runnable { final HttpConnection connection; @@ -439,16 +632,16 @@ public void run() { logger.log(Level.TRACE, () -> "exchange started "+connection.toString()); - while (true) { + while (!connection.closed) { try { runPerRequest(); if (connection.closed) { break; } - } catch (SocketException e) { + } catch (IOException e) { // these are common with clients breaking connections etc logger.log(Level.TRACE, "ServerImpl IOException", e); - socketExceptionCount.incrementAndGet(); + stats.socketExceptionCount.incrementAndGet(); closeConnection(connection); break; } catch (Exception e) { @@ -480,18 +673,27 @@ private void runPerRequest() throws IOException { logger.log(Level.TRACE,"reading request"); connection.inRequest = false; + Request req = new Request(rawin, rawout); final String requestLine = req.requestLine(); + + if("PRI * HTTP/2.0".equals(requestLine) && ServerConfig.http2OverNonSSL()) { + logger.log(Level.DEBUG,"found http2 request on non-SSL assuming prior knowledge"); + Http2Exchange exchange = new Http2Exchange(protocol, connection); + exchange.run(); + return; + } + connection.inRequest = true; - if (requestLine == null) { + if (requestLine == null || "".equals(requestLine)) { /* connection closed */ logger.log(Level.DEBUG, "no request line: closing"); closeConnection(connection); return; } - connection.requestCount++; - requestCount.incrementAndGet(); + connection.requestCount.incrementAndGet(); + stats.requestCount.incrementAndGet(); logger.log(Level.DEBUG, () -> "Exchange request line: "+ requestLine); int space = requestLine.indexOf(" "); @@ -520,6 +722,7 @@ private void runPerRequest() throws IOException { start = space + 1; String version = requestLine.substring(start); Headers headers = req.headers(); + /* check key for illegal characters, impossible since Headers class validates on mutation */ // for (var k : headers.keySet()) { // if (!isValidName(k)) { @@ -561,7 +764,7 @@ private void runPerRequest() throws IOException { } if (clen < 0) { reject(Code.HTTP_BAD_REQUEST, requestLine, - "Illegal Content-Length value"); + "Illegal Content-length value"); return; } } @@ -646,21 +849,6 @@ private void runPerRequest() throws IOException { } } - /* used to link to 2 or more Filter.Chains together */ - class LinkHandler implements HttpHandler { - - Filter.Chain nextChain; - - LinkHandler(Filter.Chain nextChain) { - this.nextChain = nextChain; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - nextChain.doFilter(exchange); - } - } - void reject(int code, String requestStr, String message) { logReply(code, requestStr, message); sendReply( @@ -674,12 +862,17 @@ void sendReply( builder.append("HTTP/1.1 ") .append(code).append(Code.msg(code)).append("\r\n"); + var informational = (code >= 100 && code < 200); + if (text != null && text.length() != 0) { - builder.append("Content-Length: ") + builder.append("Content-length: ") .append(text.length()).append("\r\n") - .append("Content-Type: text/html\r\n"); + .append("Content-type: text/html\r\n"); } else { - builder.append("Content-Length: 0\r\n"); + if (!informational) { + // no body for 1xx responses + builder.append("Content-length: 0\r\n"); + } text = ""; } if (closeNow) { @@ -693,7 +886,7 @@ void sendReply( } } catch (IOException e) { logger.log(Level.TRACE, "ServerImpl.sendReply", e); - replyErrorCount.incrementAndGet(); + stats.replyErrorCount.incrementAndGet(); closeConnection(connection); } } @@ -710,7 +903,7 @@ void logReply(int code, String requestStr, String text) { } else { r = requestStr; } - logger.log(Level.DEBUG, () -> "reply "+ r + " [" + code + " " + Code.msg(code) + "] (" + (text!=null ? text : "") + ")"); + logger.log(Level.DEBUG, () -> "reply "+ r + " [" + code + Code.msg(code) + "] (" + (text!=null ? text : "") + ")"); } void delay() { @@ -736,9 +929,12 @@ public void run() { long now = ActivityTimer.now(); for (var c : allConnections) { + if (c.drainingAt != 0 && now- c.drainingAt >= IDLE_INTERVAL / 2) { + closeConnection(c); + } if (now- c.lastActivityTime >= IDLE_INTERVAL && !c.inRequest) { logger.log(Level.DEBUG, "closing idle connection"); - idleCloseCount.incrementAndGet(); + stats.idleCloseCount.incrementAndGet(); closeConnection(c); // idle.add(c); } else if (c.noActivity && (now - c.lastActivityTime >= NEWLY_ACCEPTED_CONN_IDLE_INTERVAL)) { diff --git a/src/main/java/robaho/net/httpserver/ServerStats.java b/src/main/java/robaho/net/httpserver/ServerStats.java new file mode 100644 index 0000000..678fdd8 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/ServerStats.java @@ -0,0 +1,33 @@ +package robaho.net.httpserver; + +import java.util.concurrent.atomic.AtomicLong; + +class ServerStats { + final AtomicLong connectionCount = new AtomicLong(); + final AtomicLong requestCount = new AtomicLong(); + final AtomicLong handleExceptionCount = new AtomicLong(); + final AtomicLong socketExceptionCount = new AtomicLong(); + final AtomicLong idleCloseCount = new AtomicLong(); + final AtomicLong replyErrorCount = new AtomicLong(); + final AtomicLong maxConnectionsExceededCount = new AtomicLong(); + + private volatile long lastStatsTime = System.currentTimeMillis(); + + public String stats() { + long now = System.currentTimeMillis(); + double secs = (now-lastStatsTime)/1000.0; + lastStatsTime = now; + + long _requests = requestCount.getAndSet(0); + + return + "Connections Since: "+connectionCount.getAndSet(0)+"\n" + + "Requests Since: "+_requests+"\n" + + "Requests/sec: "+(long)(_requests/secs)+"\n"+ + "Total Handler Exceptions: "+handleExceptionCount.get()+"\n"+ + "Total Socket Exceptions: "+socketExceptionCount.get()+"\n"+ + "Total Max Connections Exceeded: "+maxConnectionsExceededCount.get()+"\n"+ + "Total Idle Closes: "+idleCloseCount.get()+"\n"+ + "Total Reply Errors: "+replyErrorCount.get()+"\n"; + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/Utils.java b/src/main/java/robaho/net/httpserver/Utils.java index 61d28b2..9fe52dc 100644 --- a/src/main/java/robaho/net/httpserver/Utils.java +++ b/src/main/java/robaho/net/httpserver/Utils.java @@ -22,7 +22,6 @@ * or visit www.oracle.com if you need additional information or have any * questions. */ - package robaho.net.httpserver; /** @@ -36,9 +35,9 @@ public class Utils { private static final boolean[] QUOTED_PAIR = new boolean[256]; static { - char[] allowedTokenChars = ("!#$%&'*+-.^_`|~0123456789" + - "abcdefghijklmnopqrstuvwxyz" + - "ABCDEFGHIJKLMNOPQRSTUVWXYZ").toCharArray(); + char[] allowedTokenChars = ("!#$%&'*+-.^_`|~0123456789" + + "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ").toCharArray(); for (char c : allowedTokenChars) { TCHAR[c] = true; } @@ -87,4 +86,32 @@ public static boolean isQuotedStringContent(String token) { } return true; } + + /** + * efficient contains() ignoring case + */ + public static boolean containsIgnoreCase(String src, String what) { + final int length = what.length(); + if (length == 0) { + return true; // Empty string is contained + } + final char firstLo = Character.toLowerCase(what.charAt(0)); + final char firstUp = Character.toUpperCase(what.charAt(0)); + + final int srcLength = src.length(); + + for (int i = srcLength - length; i >= 0; i--) { + // Quick check before calling the more expensive regionMatches() method: + final char ch = src.charAt(i); + if (ch != firstLo && ch != firstUp) { + continue; + } + + if (src.regionMatches(true, i, what, 0, length)) { + return true; + } + } + + return false; + } } diff --git a/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java b/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java index 9ee2eb1..9b6b3e0 100644 --- a/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java +++ b/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java @@ -13,7 +13,7 @@ public class ContentEncoding { * @return the provided content encoding or the default */ public static String encoding(Headers headers) { - List values = headers.get("content-encoding"); + List values = headers.get("Content-encoding"); if (values == null) { return defaultCharset; } diff --git a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java index 8d6f169..def4b3b 100644 --- a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java +++ b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java @@ -14,6 +14,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -23,6 +24,8 @@ * parse multipart form data */ public class MultipartFormParser { + static final Logger logger = Logger.getLogger("robaho.net.httpserver.MultipartFormParser"); + /** * a multipart part. * @@ -38,7 +41,7 @@ public record Part(String contentType, String filename, String data, File file) } - private record PartMetadata(String name, String filename) { + private record PartMetadata(String contentType, String name, String filename) { } @@ -66,7 +69,8 @@ public static Map> parse(String encoding, String content_type List headers = new LinkedList<>(); - System.out.println("reading until start of part"); + logger.finer(() -> "reading multipart form data with boundary '%s'".formatted(boundary)); + // read until boundary found int matchCount = 2; // starting at 2 allows matching non-compliant senders. rfc says CRLF is part of // boundary marker @@ -78,7 +82,6 @@ public static Map> parse(String encoding, String content_type if (c == boundaryCheck[matchCount]) { matchCount++; if (matchCount == boundaryCheck.length - 2) { - System.out.println("found boundary marker"); break; } } else { @@ -99,7 +102,7 @@ public static Map> parse(String encoding, String content_type while (true) { // read part headers until blank line - System.out.println("reading part headers"); + while (true) { s = readLine(charset, is); if (s == null) { @@ -111,7 +114,6 @@ public static Map> parse(String encoding, String content_type headers.add(s); } - System.out.println("reading part data"); // read part data - need to detect end of part PartMetadata meta = parseHeaders(headers); @@ -120,12 +122,12 @@ public static Map> parse(String encoding, String content_type if (meta.filename == null) { var bos = new ByteArrayOutputStream(); os = bos; - addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(null, null, bos.toString(charset), null)); + addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(meta.contentType, null, bos.toString(charset), null)); } else { File file = Path.of(storage.toString(), meta.filename).toFile(); file.deleteOnExit(); os = new NoSyncBufferedOutputStream(new FileOutputStream(file)); - addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(null, meta.filename, null, file)); + addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(meta.contentType, meta.filename, null, file)); } try (os) { @@ -138,7 +140,6 @@ public static Map> parse(String encoding, String content_type if (c == boundaryCheck[matchCount]) { matchCount++; if (matchCount == boundaryCheck.length) { - System.out.println("found boundary marker"); break; } } else { @@ -170,6 +171,7 @@ public static Map> parse(String encoding, String content_type private static PartMetadata parseHeaders(List headers) { String name = null; String filename = null; + String contentType = null; for (var header : headers) { String[] parts = header.split(":", 2); if ("content-disposition".equalsIgnoreCase(parts[0])) { @@ -188,9 +190,11 @@ private static PartMetadata parseHeaders(List headers) { } } + } else if ("content-type".equalsIgnoreCase(parts[0])) { + contentType = parts[1].trim(); } } - return new PartMetadata(name, filename); + return new PartMetadata(contentType, name, filename); } private static String readLine(Charset charset, InputStream is) throws IOException { diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java new file mode 100644 index 0000000..d49310c --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -0,0 +1,494 @@ +package robaho.net.httpserver.http2; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.HttpConnection; +import robaho.net.httpserver.OpenAddressIntMap; +import robaho.net.httpserver.OptimizedHeaders; +import robaho.net.httpserver.ServerConfig; +import robaho.net.httpserver.http2.hpack.HPackContext; +import robaho.net.httpserver.http2.hpack.HTTP2HeaderField; +import robaho.net.httpserver.http2.hpack.HeaderFields; +import robaho.net.httpserver.http2.frame.BaseFrame; +import robaho.net.httpserver.http2.frame.ContinuationFrame; +import robaho.net.httpserver.http2.frame.DataFrame; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.frame.FrameSerializer; +import robaho.net.httpserver.http2.frame.FrameType; +import robaho.net.httpserver.http2.frame.GoawayFrame; +import robaho.net.httpserver.http2.frame.HeadersFrame; +import robaho.net.httpserver.http2.frame.PingFrame; +import robaho.net.httpserver.http2.frame.ResetStreamFrame; +import robaho.net.httpserver.http2.frame.SettingIdentifier; +import robaho.net.httpserver.http2.frame.SettingParameter; +import robaho.net.httpserver.http2.frame.SettingsFrame; +import robaho.net.httpserver.http2.frame.SettingsMap; +import robaho.net.httpserver.http2.frame.WindowUpdateFrame; + +public class HTTP2Connection { + + static final String PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; + static final String PARTIAL_PREFACE = "\r\nSM\r\n\r\n"; + + final private InputStream inputStream; + final OutputStream outputStream; + + private int lastSeenStreamId = 0; + + final OpenAddressIntMap http2Streams = new OpenAddressIntMap(16); + + private final SettingsMap remoteSettings = new SettingsMap(); + private final SettingsMap localSettings = new SettingsMap(); + + private final StreamHandler handler; + + final HttpConnection httpConnection; + + final Logger logger; + final HPackContext hpack = new HPackContext(); + + final AtomicLong sendWindow = new AtomicLong(65535); + final AtomicInteger receiveWindow = new AtomicInteger(65535); + final AtomicInteger requestsInProgress = new AtomicInteger(); + + final HTTP2Stats stats; + + private final int connectionWindowSize; + + private int maxConcurrentStreams = -1; + private int highNumberStreams = 0; + + private final Lock lock = new ReentrantLock(); + private final AtomicBoolean closed = new AtomicBoolean(false); + + /** + * Constructor to instantiate HTTP2Connection object + * + * @param input HTTP2Client passes the ExBufferedInputStream + * @param output + */ + public HTTP2Connection(HttpConnection httpConnection, HTTP2Stats stats, InputStream input, OutputStream output, StreamHandler handler) { + this.httpConnection = httpConnection; + this.inputStream = input; + this.outputStream = output; + this.handler = handler; + this.stats = stats; + this.logger = System.getLogger("robaho.net.httpserver.http2"); + + connectionWindowSize = ServerConfig.http2ConnectionWindowSize(); + + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE, ServerConfig.http2MaxFrameSize())); + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, ServerConfig.http2InitialWindowSize())); + + if (ServerConfig.http2MaxConcurrentStreams() != -1) { + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS, ServerConfig.http2MaxConcurrentStreams())); + } + logger.log(Level.DEBUG, "opened http2 connection " + httpConnection + ", max concurrent streams " + ServerConfig.http2MaxConcurrentStreams()); + } + + public void debug() { + logger.log(Level.INFO,toString()+" receive window "+receiveWindow.get()+" send window "+sendWindow.get()+" in progress "+requestsInProgress.get()); + for(var stream : http2Streams.values()) { + stream.debug(); + } + } + + void lock() { + lock.lock(); + } + + void unlock() { + lock.unlock(); + } + + @Override + public String toString() { + return "{" + httpConnection + ", streams=" + http2Streams.size() + ", high " + highNumberStreams + "}"; + } + + public void close() { + if(closed.compareAndSet(false,true)) { + for (HTTP2Stream stream : http2Streams.values()) { + stream.close(); + } + } + } + + public SettingsMap getRemoteSettings() { + return remoteSettings; + } + + public SettingsMap getLocalSettings() { + return localSettings; + } + + public void writeFrame(byte[] frame) throws IOException { + writeFrame(List.of(frame)); + } + + /** + * writes a frame that consists of multiple byte arrays. the method is + * designed for low-volume frames where the overhead of creating a new byte + * array is minimal. Otherwise, the data/frame should be written directly to + * the output stream, e.g. see HTTP2Stream.Http2OutputStream + */ + public void writeFrame(List partials) throws IOException { + lock(); + try { + logger.log(Level.TRACE, () -> "sending frame " + FrameHeader.debug(partials.get(0))); + for (var frame : partials) { + outputStream.write(frame); + } + outputStream.flush(); + stats.flushes.incrementAndGet(); + } finally { + unlock(); + } + } + + /** + * Function to validate the PREFACE received on the input stream from the + * remote system + * + * @return true if preface is valid + * @throws IOException + */ + public boolean hasProperPreface() throws IOException { + String preface_match = (httpConnection.isSSL()) ? PREFACE : PARTIAL_PREFACE; + byte[] preface = new byte[preface_match.length()]; + inputStream.read(preface); + String prefaceStr = new String(preface, 0, preface.length); + return prefaceStr.equals(preface_match); + } + + public boolean isClosed() { + return closed.get(); + } + + public void handle() throws Exception { + try { + processFrames(); + } catch (HTTP2Exception e) { + logger.log(Level.DEBUG, "exception on http2 connection", e); + sendGoAway(e.getErrorCode()); + throw e; + } + } + + private void processFrames() throws Exception { + boolean inHeaders = false; + int openStreamId = 0; + + List headerBlockFragments = new ArrayList(); + + // main HTTP2 + while (!httpConnection.isClosed()) { + BaseFrame frame = FrameSerializer.deserialize(inputStream); + // System.out.println("Received frame: " + frame.getHeader()); + + int streamId = frame.getHeader().getStreamIdentifier(); + if (streamId != 0 && streamId % 2 == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "invalid stream id " + streamId + " on type " + frame.getHeader().getType()); + } + + // rfc7540 (section 6.5): SETTINGS frames always apply to a + // connection, never a single stream. + switch (frame.getHeader().getType()) { + case SETTINGS: + if (frame.getHeader().getFlags().contains(FrameFlag.ACK)) { + if (ServerConfig.http2MaxConcurrentStreams() != -1) { + // cannot set this until it's been acked + maxConcurrentStreams = http2Streams.size() + ServerConfig.http2MaxConcurrentStreams(); + } + continue; + } else { + updateRemoteSettings((SettingsFrame) frame); + sendSettingsAck(); + } + continue; + case GOAWAY: + GoawayFrame goaway = (GoawayFrame) frame; + if (goaway.errorCode == HTTP2ErrorCode.NO_ERROR) { + continue; + } + throw new IOException("received GOAWAY from remote " + goaway.errorCode); + case PING: + if (!frame.getHeader().getFlags().contains(FrameFlag.ACK)) { + sendPingAck((PingFrame) frame); + } + continue; + case WINDOW_UPDATE: + if (streamId == 0) { + int windowSizeIncrement = ((WindowUpdateFrame) frame).getWindowSizeIncrement(); + sendWindow.addAndGet(windowSizeIncrement); + logger.log(Level.DEBUG, "received connection window update " + windowSizeIncrement + ", new size " + sendWindow.get()); + if (sendWindow.get() > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR, "maximum window size exceeded"); + } + continue; + } + break; + case NOT_IMPLEMENTED: + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "NOT_IMPLEMENTED frame received while headers being received"); + } + if (frame.getHeader().getStreamIdentifier() == 0) { + continue; + } + break; + case DATA: + DataFrame dataFrame = (DataFrame) frame; + if (receiveWindow.addAndGet(-dataFrame.body.length) < connectionWindowSize/10) { + sendWindowUpdate(); + } + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "DATA frame received while headers being received"); + } + break; + case HEADERS: + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on open stream"); + } + if (streamId < lastSeenStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received out of order"); + } + var stream = http2Streams.get(streamId); + if (stream != null) { + if (!stream.isOpen() || stream.isHalfClosed()) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "HEADERS frame received on already closed stream"); + } else { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on already established stream"); + } + } + HeadersFrame headersFrame = (HeadersFrame) frame; + headerBlockFragments.add(headersFrame.getHeaderBlock()); + if (!headersFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + inHeaders = true; + openStreamId = streamId; + continue; + } + break; + case CONTINUATION: + if (inHeaders && frame.getHeader().getStreamIdentifier() != openStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on open stream"); + } + if (!inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "CONTINUATION frame received on closed stream"); + } + ContinuationFrame continuationFrame = (ContinuationFrame) frame; + headerBlockFragments.add(continuationFrame.getHeaderBlock()); + if (!continuationFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + continue; + } + break; + case PRIORITY: + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "PRIORITY frame received during headers receive"); + } + if (streamId == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "PRIORITY frame received on stream 0"); + } + // stream priority is ignore for now + continue; + case RST_STREAM: + ResetStreamFrame resetFrame = (ResetStreamFrame) frame; + if (resetFrame.errorCode == HTTP2ErrorCode.NO_ERROR) { + continue; + } + if (streamId == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "RST_STREAM frame received on stream 0"); + } + if (http2Streams.get(streamId) == null) { + if (streamId <= lastSeenStreamId) { + continue; + } + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "RST_STREAM frame received on non-existent stream"); + } + break; + } + + // we can only get here if we have a complete set of headers + HTTP2Stream targetStream = http2Streams.get(streamId); + + if (targetStream != null) { + // found existing stream + } else if (lastSeenStreamId < streamId) { + int currentSize = http2Streams.size(); + if (maxConcurrentStreams != -1 && currentSize >= maxConcurrentStreams) { + throw new HTTP2Exception(HTTP2ErrorCode.REFUSED_STREAM); + } + highNumberStreams = Math.max(highNumberStreams, currentSize); + byte[] headerBlock = Utils.combineByteArrays(headerBlockFragments); + HeaderFields fields = new HeaderFields(); + fields.addAll(hpack.decodeFieldSegments(headerBlock)); + // streamID is not present and has to be greater than all + // the stream IDs present + fields.validate(); + Headers requestHeaders = new OptimizedHeaders(fields.size()*2); + for (HTTP2HeaderField field : fields) { + if (field.value == null) { + logger.log(Level.TRACE, () -> "ignoring null header for " + field.getName()); + } else { + requestHeaders.add(field.normalizedName, field.value); + } + } + headerBlockFragments.clear(); + inHeaders = false; + targetStream = new HTTP2Stream(streamId, this, requestHeaders, handler); + http2Streams.put(streamId, targetStream); + lastSeenStreamId = streamId; + } else { + if (streamId <= lastSeenStreamId) { + if(frame.getHeader().getType()==FrameType.WINDOW_UPDATE) { + // must accept window update even if stream is closed + logger.log(Level.TRACE,() -> "received WINDOW_UPDATE on closed stream "+streamId); + continue; + } + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "frame "+frame.getHeader().getType()+ ", length "+ frame.getHeader().getLength()+", stream " + streamId + " is closed"); + } + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "frame "+frame.getHeader().getType()+", stream "+streamId+" not in order"); + } + + targetStream.processFrame(frame); + } + } + + public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2Exception { + logger.log(Level.TRACE, () -> "updating remote settings"); + + for (SettingParameter parameter : remoteSettingFrame.getSettingParameters()) { + long oldInitialWindowSize = remoteSettings.getOrDefault(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, SettingParameter.DEFAULT_INITIAL_WINDOWSIZE).value; + if (parameter.identifier == SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE) { + if (parameter.value > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR, "Invalid value for SETTINGS_INITIAL_WINDOW_SIZE " + parameter.value); + } + logger.log(Level.DEBUG, () -> "received initial window size of " + parameter.value); + for (var stream : http2Streams.values()) { + stream.sendWindow.addAndGet(parameter.value - oldInitialWindowSize); + } + } + if (parameter.identifier == SettingIdentifier.SETTINGS_MAX_FRAME_SIZE) { + logger.log(Level.DEBUG, () -> "received max frame size " + parameter.value); + } + getRemoteSettings().set(parameter); + } + } + + public void sendSettingsAck() throws IOException { + try { + byte[] frame = FrameHeader.encode(0, FrameType.SETTINGS, FlagSet.of(FrameFlag.ACK), 0); + HTTP2Connection.this.writeFrame(frame); + } finally { + logger.log(Level.TRACE, () -> "sent Settings Ack"); + } + } + + public void sendMySettings() throws IOException { + try { + FrameHeader header = new FrameHeader(0, FrameType.SETTINGS, FrameFlag.NONE, 0); + SettingsFrame frame = new SettingsFrame(header); + localSettings.forEach(setting -> frame.getSettingParameters().add(setting)); + HTTP2Connection.this.writeFrame(frame.encode()); + } finally { + logger.log(Level.TRACE, () -> "sent My Settings"); + } + } + + public void sendWindowUpdate() throws IOException { + int current = receiveWindow.get(); + try { + int increment = connectionWindowSize-current; + receiveWindow.addAndGet(increment); + WindowUpdateFrame frame = new WindowUpdateFrame(0, increment); + HTTP2Connection.this.writeFrame(frame.encode()); + } finally { + logger.log(Level.DEBUG, () -> "sent connection window update, previous "+current+", now "+ receiveWindow.get()); + } + } + + InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) httpConnection.getRemoteAddress(); + } + + InetSocketAddress getLocalAddress() { + return (InetSocketAddress) httpConnection.getLocalAddress(); + } + + public void sendGoAway(HTTP2ErrorCode errorCode) throws IOException { + lock(); + try { + GoawayFrame frame = new GoawayFrame(errorCode, lastSeenStreamId); + frame.writeTo(outputStream); + outputStream.flush(); + } finally { + unlock(); + } + logger.log(Level.TRACE, () -> "Sent GoAway " + errorCode + ", last stream " + lastSeenStreamId); + } + + public void sendResetStream(HTTP2ErrorCode errorCode, int streamId) throws IOException { + ResetStreamFrame frame = new ResetStreamFrame(errorCode, streamId); + HTTP2Connection.this.writeFrame(frame.encode()); + logger.log(Level.TRACE, () -> "Sent ResetStream " + errorCode); + } + + public void sendPing() throws IOException { + PingFrame frame = new PingFrame(); + HTTP2Connection.this.writeFrame(frame.encode()); + stats.pingsSent.incrementAndGet(); + logger.log(Level.TRACE, () -> "Sent Ping "); + } + + private void sendPingAck(PingFrame ping) throws IOException { + PingFrame frame = new PingFrame(ping); + HTTP2Connection.this.writeFrame(frame.encode()); + logger.log(Level.TRACE, "Sent Ping Ack"); + } + + public static interface StreamHandler { + + void handleStream(HTTP2Stream stream, InputStream in, OutputStream out) throws IOException; + + Executor getExecutor(); + } + + public static void readFully(InputStream inputStream, byte[] buffer) throws IOException { + if (buffer.length == 0) { + return; + } + + int bytesRead = 0; + int offset = 0; + int length = buffer.length; + while (bytesRead != -1 && offset < length) { + bytesRead = inputStream.read(buffer, offset, length - offset); + if (bytesRead != -1) { + offset += bytesRead; + } + } + if (offset == 0) { + throw new EOFException("end of stream detected"); + } + if (offset < length) { + throw new IOException("failed to read the full buffer"); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java b/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java new file mode 100644 index 0000000..15b63fc --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java @@ -0,0 +1,40 @@ +package robaho.net.httpserver.http2; + +public enum HTTP2ErrorCode { + + NO_ERROR (0x0), + PROTOCOL_ERROR (0x1), + INTERNAL_ERROR (0x2), + FLOW_CONTROL_ERROR (0x3), + SETTINGS_TIMEOUT (0x4), + STREAM_CLOSED (0x5), + FRAME_SIZE_ERROR (0x6), + REFUSED_STREAM (0x7), + CANCEL (0x8), + COMPRESSION_ERROR (0x9), + CONNECT_ERROR (0xa), + ENHANCE_YOUR_CALM (0xb), + INADEQUATE_SECURITY (0xc), + HTTP_1_1_REQUIRED (0xd); + + int value; + + HTTP2ErrorCode(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static HTTP2ErrorCode getEnum(int value) { + HTTP2ErrorCode result = HTTP2ErrorCode.NO_ERROR; + + for (HTTP2ErrorCode e : HTTP2ErrorCode.values()) { + if (e.getValue() == value) + result = e; + } + return result; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java new file mode 100644 index 0000000..be20189 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java @@ -0,0 +1,30 @@ +package robaho.net.httpserver.http2; + +public class HTTP2Exception extends Exception { + + private final HTTP2ErrorCode errorCode; + + public HTTP2Exception(HTTP2ErrorCode errorCode) { + this(errorCode,""); + } + + public HTTP2Exception(HTTP2ErrorCode errorCod, String message) { + super(message); + errorCode = errorCod; + } + + public HTTP2Exception(HTTP2ErrorCode errorCod, Throwable cause) { + super(cause); + errorCode = errorCod; + } + + public HTTP2Exception(String message,Exception cause) { + super(message,cause); + errorCode = HTTP2ErrorCode.INTERNAL_ERROR; + } + public HTTP2ErrorCode getErrorCode() + { + return errorCode; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java new file mode 100644 index 0000000..00bc109 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java @@ -0,0 +1,39 @@ +package robaho.net.httpserver.http2; + +import java.util.concurrent.atomic.AtomicLong; + +public class HTTP2Stats { + public final AtomicLong activeStreams = new AtomicLong(); + public final AtomicLong bytesSent = new AtomicLong(); + public final AtomicLong framesSent = new AtomicLong(); + public final AtomicLong flushes = new AtomicLong(); + public final AtomicLong sslConnections = new AtomicLong(); + public final AtomicLong nonsslConnections = new AtomicLong(); + public final AtomicLong totalStreams = new AtomicLong(); + public final AtomicLong pauses = new AtomicLong(); + public final AtomicLong pingsSent = new AtomicLong(); + + private volatile long lastStatsTime = System.currentTimeMillis(); + + public String stats() { + long now = System.currentTimeMillis(); + double secs = (now-lastStatsTime)/1000.0; + lastStatsTime = now; + + long _bytes = bytesSent.getAndSet(0); + long _frames = framesSent.getAndSet(0); + + return + "Http2 SSL Connections Since: "+sslConnections.getAndSet(0)+"\n" + + "Http2 Non-SSL Connections Since: "+nonsslConnections.getAndSet(0)+"\n" + + "Http2 Streams Since: "+totalStreams.getAndSet(0)+"\n" + + "Http2 Active Streams: "+activeStreams.get()+"\n" + + "Http2 Frames Sent/sec: "+(long)(_frames/(secs))+"\n"+ + "Http2 Bytes Sent/sec: "+(long)(_bytes/(secs))+"\n"+ + "Http2 Avg Frame Size: "+(long)(_frames==0 ? 0 : _bytes/_frames)+"\n"+ + "Http2 Flushes/sec: "+(long)(flushes.getAndSet(0)/(secs))+"\n"+ + "Http2 Pauses/sec: "+(long)(pauses.getAndSet(0)/(secs))+"\n"+ + "Http2 Pings Sent Since: "+pingsSent.getAndSet(0)+"\n"; + + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java new file mode 100644 index 0000000..f2b201a --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -0,0 +1,450 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; + +import javax.net.ssl.SSLSession; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.NoSyncBufferedOutputStream; +import robaho.net.httpserver.OptimizedHeaders; +import robaho.net.httpserver.http2.hpack.HPackContext; +import robaho.net.httpserver.http2.frame.BaseFrame; +import robaho.net.httpserver.http2.frame.DataFrame; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.frame.FrameType; +import robaho.net.httpserver.http2.frame.ResetStreamFrame; +import robaho.net.httpserver.http2.frame.SettingIdentifier; +import robaho.net.httpserver.http2.frame.WindowUpdateFrame; + +public class HTTP2Stream { + + private final int streamId; + + // needs to be accessible for connection to adjust based on SettingsFrame + final AtomicLong sendWindow = new AtomicLong(65535); + private final AtomicLong receiveWindow = new AtomicLong(65535); + private final int initialWindowSize; + + private final HTTP2Connection connection; + private final Logger logger; + private final OutputStream outputStream; + private final DataIn dataIn; + private final HTTP2Connection.StreamHandler handler; + private final Headers requestHeaders; + private final Headers responseHeaders = new OptimizedHeaders(16); + private final AtomicBoolean headersSent = new AtomicBoolean(false); + + private volatile Thread thread; + private volatile boolean streamOpen = true; + // halfClosed is set when a END_STREAM is received. The streams are bidirectional. + private volatile boolean halfClosed = false; + // streamOutputClosed is when the handler, either via close(), or sendResponseHeaders(code,-1) closes the output + private volatile boolean streamOutputClosed = false; + private volatile AtomicBoolean handlingRequest = new AtomicBoolean(false); + + private long dataInSize = 0; + + public HTTP2Stream(int streamId, HTTP2Connection connection, Headers requestHeaders, HTTP2Connection.StreamHandler handler) throws IOException { + this.streamId = streamId; + this.connection = connection; + this.logger = connection.logger; + this.requestHeaders = requestHeaders; + this.handler = handler; + this.dataIn = new DataIn(); + this.outputStream = new NoSyncBufferedOutputStream(new Http2OutputStream(streamId)); + var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); + if(setting!=null) { + sendWindow.set((int)(setting.value)); + } + setting = connection.getLocalSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); + if(setting!=null) { + receiveWindow.set((int)(setting.value)); + initialWindowSize = (int)setting.value; + } else { + initialWindowSize = 65535; + } + if(logger.isLoggable(Level.TRACE)) { + logger.log(Level.TRACE,() -> "new stream, send window size "+sendWindow.get()+", receive window size "+receiveWindow.get()+" on stream "+streamId); + } + } + + public OutputStream getOutputStream() { + return outputStream; + } + + public Headers getRequestHeaders() { + return requestHeaders; + } + + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public String toString() { + return connection.httpConnection.toString()+" stream "+streamId; + } + + public void debug() { + logger.log(Level.INFO,connection.toString()+", stream "+streamId+" open "+streamOpen+" half closed "+halfClosed+", streamOutputClosed "+streamOutputClosed+", thread "+thread); + logger.log(Level.INFO,connection.toString()+", stream "+streamId+" data in size "+dataInSize+" expected "+expectedSize()); + logger.log(Level.INFO,""+Arrays.toString(thread.getStackTrace())); + } + + public boolean isOpen() { + return streamOpen; + } + + public boolean isHalfClosed() { + return halfClosed; + } + + public SSLSession getSSLSession() { + return connection.httpConnection.getSSLSession(); + } + + private long expectedSize() { + if(requestHeaders.containsKey("Content-length")) { + return Long.parseLong(requestHeaders.getFirst("Content-length")); + } + return -1; + } + + public void close() { + streamOpen = false; + + if(connection.http2Streams.put(streamId,null)==null) { + return; + } + + logger.log(Level.TRACE,() -> "closing stream "+streamId); + + try { + dataIn.close(); + outputStream.close(); + if(thread!=null) + thread.interrupt(); + } catch (IOException e) { + if(!connection.isClosed()) { + connection.close(); + logger.log(connection.httpConnection.requestCount.get()>0 ? Level.WARNING : Level.DEBUG, "IOException closing http2 stream",e); + } + } finally { + } + } + + public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { + + switch (frame.getHeader().getType()) { + case HEADERS: + case CONTINUATION: + if(halfClosed) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); + } + halfClosed = frame.getHeader().getFlags().contains(FrameFlag.END_STREAM); + performRequest(); + break; + case DATA: + DataFrame dataFrame = (DataFrame) frame; + logger.log(Level.TRACE,()->"received data frame, length "+dataFrame.body.length+" on stream "+streamId); + if(halfClosed) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); + } + if(!streamOpen) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + dataIn.enqueue(dataFrame.body); + dataInSize += dataFrame.body.length; + if (dataFrame.getHeader().getFlags().contains(FrameFlag.END_STREAM)) { + long expected = expectedSize(); + if(expected!=-1 && dataInSize!=expected) { + connection.sendResetStream(HTTP2ErrorCode.PROTOCOL_ERROR, streamId); + close(); + break; + } + halfClosed = true; + dataIn.wakeupReader(); + } + break; + case PRIORITY: + if(streamOpen) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + break; + case RST_STREAM: + ResetStreamFrame resetFrame = (ResetStreamFrame) frame; + logger.log(Level.DEBUG,"received reset stream "+resetFrame.errorCode+", on stream "+streamId); + halfClosed = true; + close(); + break; + case WINDOW_UPDATE: + int windowSizeIncrement = ((WindowUpdateFrame)frame).getWindowSizeIncrement(); + if(sendWindow.addAndGet(windowSizeIncrement)> 2147483647) { + connection.sendResetStream(HTTP2ErrorCode.FLOW_CONTROL_ERROR, streamId); + close(); + } + logger.log(Level.DEBUG,"received window update "+windowSizeIncrement+", new size "+sendWindow.get()+", on stream "+streamId); + break; + default: + break; + } + } + + private void performRequest() throws IOException, HTTP2Exception { + if(!handlingRequest.compareAndSet(false, true)) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"already received headers for stream "+streamId); + } + connection.httpConnection.requestCount.incrementAndGet(); + connection.requestsInProgress.incrementAndGet(); + connection.stats.activeStreams.incrementAndGet(); + + InputStream in = halfClosed ? InputStream.nullInputStream() : dataIn; + + handler.getExecutor().execute(() -> { + thread = Thread.currentThread(); + try { + handler.handleStream(this,in,outputStream); + } catch (IOException ex) { + logger.log(Level.DEBUG,"io exception on stream "+streamId,ex); + close(); + } + }); + } + /** + * @param closeStream if true the output stream is closed, and any attempts + * to write data to the stream will fail. This is an optimization that + * allows the CLOSE_STREAM bit to be set in the Headers frame, reducing the + * packet count. + */ + public void writeResponseHeaders(boolean closeStream) throws IOException { + if (headersSent.compareAndSet(false, true)) { + connection.lock(); + try { + HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId, closeStream); + if (closeStream) { + streamOutputClosed = true; + } + } finally { + connection.unlock(); + } + } + } + + public InetSocketAddress getLocalAddress() { + return connection.getLocalAddress(); + } + + public InetSocketAddress getRemoteAddress() { + return connection.getRemoteAddress(); + } + + class Http2OutputStream extends OutputStream { + private static final FlagSet END_STREAM = FlagSet.of(FrameFlag.END_STREAM); + + private final int streamId; + private final int max_frame_size; + private boolean closed; + + public Http2OutputStream(int streamId) { + this.streamId = streamId; + var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE); + max_frame_size = setting!=null ? (int)setting.value : 16384; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) b}); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + connection.stats.bytesSent.addAndGet(len); + // test outside of lock so other streams can progress + while(sendWindow.get()<=0 && !connection.isClosed()) { + connection.stats.pauses.incrementAndGet(); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + writeResponseHeaders(false); + if(streamOutputClosed) { + throw new IOException("output stream was closed during headers send"); + } + while(len>0) { + int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); + if(_len<=0) { + connection.stats.pauses.incrementAndGet(); + connection.lock(); + try { + connection.stats.flushes.incrementAndGet(); + connection.outputStream.flush(); + } finally { + connection.unlock(); + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + if(connection.isClosed()) { + throw new IOException("connection closed"); + } + int remaining = len; + // logger.log(Level.TRACE,() -> "paused sending data frame, remaining "+remaining+", length "+_len+" on stream "+streamId); + continue; + } + if(connection.sendWindow.addAndGet(-_len)<0) { + // if we can't get the space from the connection window, need to retry + connection.sendWindow.addAndGet(_len); + continue; + } + connection.lock(); + try { + FrameHeader.writeTo(connection.outputStream, _len, FrameType.DATA, FrameFlag.NONE, streamId); + connection.outputStream.write(b,off,_len); + connection.stats.framesSent.incrementAndGet(); + } finally { + connection.unlock(); + } + // byte[] header = FrameHeader.encode(_len, FrameType.DATA, FrameFlag.NONE, streamId); + // byte[] data = Arrays.copyOfRange(b,off, len); + // connection.enqueue(List.of(header,data)); + off+=_len; + len-=_len; + sendWindow.addAndGet(-_len); + logger.log(Level.TRACE,() -> "sent data frame, length "+_len+", new send window "+sendWindow.get()+" on stream "+streamId); + } + } + @Override + public void flush() throws IOException { + } + @Override + public void close() throws IOException { + if(closed) return; + try { + if(connection.isClosed()) { + if(headersSent.compareAndSet(false,true)) { + logger.log(Level.WARNING,"stream connection is closed and headers not sent on stream "+streamId); + } + return; + } + writeResponseHeaders(false); + connection.lock(); + boolean lastRequest = connection.requestsInProgress.decrementAndGet() == 0; + try { + if(!streamOutputClosed) { + FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); + connection.stats.framesSent.incrementAndGet(); + } + if(lastRequest) { + connection.outputStream.flush(); + connection.stats.flushes.incrementAndGet(); + } + } finally { + connection.unlock(); + } + dataIn.close(); + } finally { + connection.stats.activeStreams.decrementAndGet(); + closed=true; + HTTP2Stream.this.close(); + } + } + } + + // the data InputStream passed to handlers + private class DataIn extends InputStream { + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private volatile Thread reader; + /** offset into the top of the queue array */ + private int offset = 0; + + public DataIn() { + } + + void enqueue(byte[] data) { + queue.add(data); + LockSupport.unpark(reader); + } + + void wakeupReader() { + LockSupport.unpark(reader); + } + + @Override + public void close() throws IOException { + if(Thread.currentThread()==reader || reader == null) { + readAllBytes(); + } else { + LockSupport.unpark(reader); + } + } + + private final byte[] single = new byte[1]; + + @Override + public int read() throws IOException { + int n = read(single, 0, 1); + return n == -1 ? -1 : single[0] & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int read = 0; + try { + reader = Thread.currentThread(); + for(;len>0;) { + byte[] data; + while((data=queue.peek())==null) { + if(read>0) { + return read; + } + if(halfClosed) return -1; + LockSupport.park(); + if(Thread.interrupted()) { + throw new IOException("interrupted"); + } + } + int available = data.length-offset; + int bytesToRead = Math.min(len, available); + System.arraycopy(data, offset, b, off, bytesToRead); + offset+=bytesToRead; + off+=bytesToRead; + len-=bytesToRead; + available-=bytesToRead; + read+=bytesToRead; + if(available==0) { // remove top buffer from queue + queue.poll(); + offset=0; + } + } + return read; + } finally { + if(receiveWindow.addAndGet(-read) "sent stream window update, receive window "+receiveWindow.get()+" on stream "+streamId); + } finally { + connection.unlock(); + } + } + } + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/Utils.java b/src/main/java/robaho/net/httpserver/http2/Utils.java new file mode 100644 index 0000000..37c9e81 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/Utils.java @@ -0,0 +1,128 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Deque; +import java.util.List; + +public class Utils { + + public static int convertToInt(byte[] bytes, int off) throws Exception { + return convertToInt(bytes, off, 4); + } + + public static int convertToInt(byte[] bytes, int off, int length) throws Exception { + int result = 0; + if (off + length <= bytes.length) { + + int counter = 0; + for (int i = off + length-1 ; i >=off ; i--) { + result |= (bytes[i] & 0x00ff) << (8 * counter); + counter++; + } + + } else { + throw new Exception(String.format("cannot read %s bytes from offset, goes beyond array boundaries", length)); + } + return result; + + } + + public static long convertToLong(byte[] bytes, int off, int length) throws Exception { + long result = 0; + if (off + length <= bytes.length) { + int counter = 0; + for (int i = off + length-1 ; i >=off ; i--) { + result |= Long.valueOf(bytes[i] & 0xff) << (8 * counter); + counter++; + } + } else { + throw new Exception(String.format("cannot read %s bytes from offset, goes beyond array boundries", length)); + } + return result; + + } + + public static void convertToBinary(byte[] buffer, int pos, int input) { + convertToBinary(buffer, pos, input, 4); + } + + public static void convertToBinary(byte[] buffer, int pos, int input, int length) { + for (int i = 0; i < length; i++) { + buffer[i+pos] = (byte) ((input >> (8 * (length-1-i))) & 255); + } + } + + public static void writeBinary(OutputStream os,int input) throws IOException { + writeBinary(os, input, 4); + } + + public static void writeBinary(OutputStream os,int input,int length) throws IOException { + for (int i = length-1; i>=0; i--) { + os.write((byte) ((input >> (8 * i)) & 0xFF)); + } + } + private static final byte[] EMPTY = new byte[0]; + + public static byte[] combineByteArrays(byte[]... blocks) { + if(blocks.length==0) return EMPTY; + if(blocks.length==1) return blocks[0]; + + int totalLength = 0; + for (byte[] block : blocks) { + totalLength += block.length; + } + + byte[] combined = new byte[totalLength]; + int offset = 0; + for (byte[] block : blocks) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + + return combined; + } + + public static byte[] combineByteArrays(List blocks) { + if(blocks.isEmpty()) return EMPTY; + if(blocks.size()==1) return blocks.get(0); + + int totalLength = 0; + for (byte[] block : blocks) { + totalLength += block.length; + } + + byte[] combined = new byte[totalLength]; + int offset = 0; + for (byte[] block : blocks) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + + return combined; + } + public static byte[] combineByteArrays(List array1,List array2) { + int totalLength = 0; + for (byte[] block : array1) { + totalLength += block.length; + } + for (byte[] block : array2) { + totalLength += block.length; + } + if(totalLength==0) return EMPTY; + + byte[] combined = new byte[totalLength]; + + int offset = 0; + for (byte[] block : array1) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + for (byte[] block : array2) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + + return combined; + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java new file mode 100644 index 0000000..605d912 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java @@ -0,0 +1,35 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public abstract class BaseFrame { + + private FrameHeader header; + + public BaseFrame(FrameHeader header) { + this.header = header; + } + + public FrameHeader getHeader() + { + return header; + } + + public void setHeader(FrameHeader header) + { + this.header = header; + } + + public abstract void writeTo(OutputStream os) throws IOException; + + public byte[] encode() { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + writeTo(bos); + } catch (IOException ignore) { + } + return bos.toByteArray(); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java new file mode 100644 index 0000000..f8107bc --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java @@ -0,0 +1,34 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class ContinuationFrame extends BaseFrame { + private final byte[] body; + + public ContinuationFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + return new ContinuationFrame(frameHeader,body); + } + public byte[] getHeaderBlock() { + return body; + } + + public byte[] encode() { + return Utils.combineByteArrays(List.of(getHeader().encode(),body)); + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java new file mode 100644 index 0000000..f3e7d04 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java @@ -0,0 +1,39 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class DataFrame extends BaseFrame { + + public final byte[] body; + public DataFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + @Override + public void writeTo(OutputStream outputStream) throws IOException { + outputStream.write(body); + } + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + int index = 0; + int padding = 0; + if(frameHeader.getFlags().contains(FrameFlag.PADDED)) { + padding = (body[index] & 0xFF)+1; + if(padding > body.length) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"padding exceeds frame size"); + } + } + if(padding>0) { + return new DataFrame(frameHeader, Arrays.copyOfRange(body,index,body.length-padding)); + } else { + return new DataFrame(frameHeader, body); + } + } + public byte[] encode() { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java new file mode 100644 index 0000000..8810fab --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java @@ -0,0 +1,87 @@ +package robaho.net.httpserver.http2.frame; + +import robaho.net.httpserver.http2.HTTP2Exception; + +/** + * An enumeration to define all the Flags that can be attached to a frame + */ +public enum FrameFlag { + + END_STREAM((byte) 0x1), + ACK((byte) 0x1), + END_HEADERS((byte) 0x4), + PADDED((byte) 0x8), + PRIORITY((byte) 0x20); + + private final byte value; + private static final byte MASK = (byte) (END_STREAM.value | END_HEADERS.value | PADDED.value | PRIORITY.value); + private static final FrameFlag[] _values = FrameFlag.values(); + + FrameFlag(byte value) { + this.value = value; + } + + public static final FlagSet NONE = new FlagSet(0,false); + + public byte getValue() { + return value; + } + + public static FlagSet getEnumSet(byte value, FrameType type) throws HTTP2Exception { + if (value == 0) { + return NONE; + } + return new FlagSet(value & MASK, type == FrameType.SETTINGS || type == FrameType.PING); + } + + public static class FlagSet { + + private final int value; + private final boolean isAck; + + FlagSet(int value, boolean isAck) { + this.value = value; + this.isAck = isAck; + } + + public byte value() { + return (byte) value; + } + public boolean contains(FrameFlag flag) { + return (value & flag.value) == flag.value; + } + public static FlagSet of(FrameFlag... flags) { + int value = 0; + boolean isAck = false; + for (FrameFlag flag : flags) { + value |= flag.value; + if (flag == ACK) { + isAck = true; + } + } + return new FlagSet(value, isAck); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + var tmp = this.value; + + if ((tmp & 1) == 1) { + sb.append(isAck ? "ACK" : "END_STREAM"); + // reset the first bit + tmp = (byte) (tmp ^ 1); + } + for (FrameFlag flag : FrameFlag._values) { + if ((tmp & flag.value) == flag.value) { + if(!sb.isEmpty()) { + sb.append(","); + } + sb.append(flag); + } + } + sb.append("]"); + return sb.toString(); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java new file mode 100644 index 0000000..0e8b4b6 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java @@ -0,0 +1,151 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; + +/** + * Create a frame header object + */ +public class FrameHeader { + + private final int length; + private final FrameType type; + private final FlagSet flags; + private final int streamIdentifier; + + /** + * 24-bit unsigned integer value that specifies length of the + * frame + */ + public int getLength() { + return length; + } + + /** + * defined as an enum FrameType, it identifies the type of the + * frame + */ + public FrameType getType() { + return type; + } + + /** + * defined as an EnumSet<FrameFlag>, it identifies flags associated with a + * particular frame + */ + public FlagSet getFlags() { + return flags; + } + + /** + * 31-bit unsigned integer uniquely identifies a frame + */ + public int getStreamIdentifier() { + return streamIdentifier; + } + + public FrameHeader(int length, FrameType type, FlagSet flags, int streamIdentifier) { + this.length = length; + this.type = type; + this.flags = flags; + this.streamIdentifier = streamIdentifier; + } + + @Override + public String toString() { + return "FrameHeader{" + + "length=" + length + + ", type=" + type + + ", flags=" + flags + + ", streamIdentifier=" + streamIdentifier + + '}'; + } + + /** + * Parse the 9 bytes frame header to determine length, type, flags and the stream identifier + * @param tmpBuffer 9 bytes frame header + * @return + * @throws Exception + */ + // TODO validate the frame size, frame number, and number of frames in session based on the SETTINGS frame + public static FrameHeader Parse(byte[] tmpBuffer) throws Exception { + FrameHeader frameHeader = null; + + FrameType type = null; + FlagSet flag = null; + int streamIdentifier = 0; + int length = 0; + int readIndex = 0; + + length = Utils.convertToInt(tmpBuffer, readIndex, 3); + readIndex += 3; + + type = FrameType.getEnum(tmpBuffer[readIndex]); + readIndex++; + + flag = FrameFlag.getEnumSet(tmpBuffer[readIndex], type); + readIndex++; + + streamIdentifier = Utils.convertToInt(tmpBuffer, readIndex); + readIndex += 4; + + streamIdentifier = streamIdentifier & 0x7FFFFFFF; + + frameHeader = new FrameHeader(length, type, flag, streamIdentifier); + + return frameHeader; + } + + public static String debug(byte[] header) { + try { + var type = FrameType.getEnum(header[3]); + return "length="+Utils.convertToInt(header, 0, 3)+", type "+type+", flags "+FrameFlag.getEnumSet(header[4], type)+", stream "+Utils.convertToInt(header, 5); + } catch (Exception ex) { + return ""; + } + } + + public void writeTo(OutputStream os) throws IOException { + Utils.writeBinary(os,this.length,3); + os.write(this.getType().value & 0xFF); + os.write(flags.value()); + Utils.writeBinary(os,this.streamIdentifier); + } + + public static void writeTo(OutputStream os, int length,FrameType frameType,FlagSet flags,int streamId) throws IOException { + Utils.writeBinary(os,length,3); + os.write(frameType.value & 0xFF); + os.write(flags.value()); + Utils.writeBinary(os,streamId); + } + + public static byte[] encode(int length,FrameType frameType,FlagSet flags,int streamId) { + byte[] buffer = new byte[9]; + Utils.convertToBinary(buffer, 0, length, 3); + buffer[3] = (byte)(frameType.value & 0xFF); + buffer[4] = (byte)(flags.value()); + Utils.convertToBinary(buffer, 5, streamId,4); + return buffer; + } + + public byte[] encode() { + byte[] buffer = new byte[9]; + Utils.convertToBinary(buffer, 0, length ,3); + buffer[3] = (byte)(type.value & 0xFF); + buffer[4] = (byte)(flags.value()); + Utils.convertToBinary(buffer, 5, streamIdentifier,4); + return buffer; + } + + /** encode into an existing byte array */ + public byte[] encode(byte[] buffer) { + Utils.convertToBinary(buffer, 0, length ,3); + buffer[3] = (byte)(type.value & 0xFF); + buffer[4] = (byte)(flags.value()); + Utils.convertToBinary(buffer, 5, streamIdentifier,4); + return buffer; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java new file mode 100644 index 0000000..270d07e --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java @@ -0,0 +1,65 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.InputStream; + +import robaho.net.httpserver.ServerConfig; +import robaho.net.httpserver.http2.HTTP2Connection; +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class FrameSerializer { + + public static BaseFrame deserialize(InputStream inputStream) throws Exception { + + BaseFrame baseFrame; + + byte[] tmpBuffer = new byte[9]; + HTTP2Connection.readFully(inputStream, tmpBuffer); + FrameHeader frameHeader = FrameHeader.Parse(tmpBuffer); + + if(frameHeader.getLength() > ServerConfig.http2MaxFrameSize()) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + byte[] body = new byte[frameHeader.getLength()]; + HTTP2Connection.readFully(inputStream, body); + + switch (frameHeader.getType()) { + case HEADERS: + baseFrame = HeadersFrame.parse(body, frameHeader); + break; + case CONTINUATION: + baseFrame = ContinuationFrame.parse(body,frameHeader); + break; + case DATA: + baseFrame = DataFrame.parse(body,frameHeader); + break; + case GOAWAY: + baseFrame = GoawayFrame.parse(body,frameHeader); + break; + case PING: + baseFrame = PingFrame.parse(body,frameHeader); + break; + case PRIORITY: + baseFrame = PriorityFrame.parse(body,frameHeader); + break; + case PUSH_PROMISE: + baseFrame = PushPromiseFrame.parse(body,frameHeader); + break; + case RST_STREAM: + baseFrame = ResetStreamFrame.parse(body,frameHeader); + break; + case SETTINGS: + baseFrame = SettingsFrame.parse(body, frameHeader); + break; + case WINDOW_UPDATE: + baseFrame = WindowUpdateFrame.parse(body, frameHeader); + break; + default: + baseFrame = NotImplementedFrame.parse(body,frameHeader); + break; + } + + return baseFrame; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java new file mode 100644 index 0000000..170a403 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java @@ -0,0 +1,35 @@ +package robaho.net.httpserver.http2.frame; + +/** + * [rfc7540: Section 11.2] A enumeration of all the Frame types introduced in + * HTTP2 This specification defines a number of frame types, each identified by + * a unique 8-bit type code. Each frame type serves a distinct purpose in the + * establishment and management either of the connection as a whole or of + * individual streams. + */ +public enum FrameType { + + DATA((byte) 0x0), HEADERS((byte) 0x1), PRIORITY((byte) 0x2), RST_STREAM((byte) 0x3), + SETTINGS((byte) 0x4), PUSH_PROMISE((byte) 0x5), PING((byte) 0x6), + GOAWAY((byte) 0x7), WINDOW_UPDATE((byte) 0x8), CONTINUATION((byte) 0x9), + NOT_IMPLEMENTED((byte) 0xA); + + final byte value; + + private static final FrameType[] _values = FrameType.values(); + + FrameType(byte value) { + this.value = value; + } + + public byte getValue() { + return value; + } + + public static FrameType getEnum(int value) { + if(value < 0 || value > 0x9) { + return NOT_IMPLEMENTED; + } + return _values[value]; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java new file mode 100644 index 0000000..c951465 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java @@ -0,0 +1,49 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class GoawayFrame extends BaseFrame { + public final HTTP2ErrorCode errorCode; + public final int lastSeenStream; + + public GoawayFrame(FrameHeader header,HTTP2ErrorCode errorCode) { + this(header,errorCode,0); + } + + public GoawayFrame(FrameHeader header,HTTP2ErrorCode errorCode,int lastSeenStream) { + super(header); + this.errorCode = errorCode; + this.lastSeenStream = lastSeenStream; + } + + public GoawayFrame(HTTP2ErrorCode errorCode,int lastSeenStream) { + this(new FrameHeader(8,FrameType.GOAWAY,FrameFlag.NONE,0),errorCode,lastSeenStream); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + Utils.writeBinary(os, lastSeenStream); + Utils.writeBinary(os, errorCode.getValue()); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + if(frameHeader.getStreamIdentifier()!=0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + if(body.length<8) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + try { + var errorCode = Utils.convertToInt(body, 4, 4); + return new GoawayFrame(frameHeader,HTTP2ErrorCode.getEnum(errorCode)); + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java new file mode 100644 index 0000000..46f7023 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java @@ -0,0 +1,176 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.EnumSet; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; + +/** + * [rfc7540 Section 6.2] The HEADERS frame (type=0x1) is used to open a stream + * (Section 5.1), and additionally carries a header block fragment. HEADERS + * frames can be sent on a stream in the "idle", "reserved (local)", "open", or + * "half-closed (remote)" state. + */ +public class HeadersFrame extends BaseFrame { + + private int padLength; + private boolean isExclusive; + private long dependentStreamId; + private int weight; + private byte[] headerBlock; + private byte[] padding; + + public HeadersFrame() { + this(new FrameHeader(0, FrameType.HEADERS, FrameFlag.NONE, 0)); + } + + public HeadersFrame(FrameHeader header) { + super(header); + } + + /** + * An 8-bit field containing the length of the frame padding in units of + * octets. This field is only present if the PADDED flag is set. + */ + public int getPadLength() { + return padLength; + } + + public void setPadLength(int padLength) { + this.padLength = padLength; + } + + /** + * A single-bit flag indicating that the stream dependency is exclusive (see + * Section 5.3). This field is only present if the PRIORITY flag is set. + */ + public boolean getIsExclusive() { + return isExclusive; + } + + public void setIsExclusive(boolean isExclusive) { + this.isExclusive = isExclusive; + } + + /** + * A 31-bit stream identifier for the stream that this stream depends on + * (see Section 5.3). This field is only present if the PRIORITY flag is + * set. + */ + public long getDependentStream() { + return dependentStreamId; + } + + public void setDependentStream(long streamIdentifier) { + this.dependentStreamId = streamIdentifier; + } + + /** + * An unsigned 8-bit integer representing a priority weight for the stream + * (see Section 5.3). Add one to the value to obtain a weight between 1 and + * 256. This field is only present if the PRIORITY flag is set. + */ + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = weight; + } + + /** + * A header block fragment (Section 4.3). + */ + public byte[] getHeaderBlock() { + return headerBlock; + } + + public void setHeaderBlock(byte[] headerBlock) { + this.headerBlock = headerBlock; + } + + /** + * Padding octets. + */ + public byte[] getPadding() { + return padding; + } + + public void setPadding(byte[] padding) { + this.padding = padding; + } + + /** + * + * @param frameBody + * payload of the HeadersFrame after consuming the FrameHeader + * @param header + * FrameHeader + * @return HeadersFrame object + * @throws HTTP2Exception + * @throws Exception + */ + public static HeadersFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception, Exception { + + if(frameBody == null) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + if(header.getLength() != frameBody.length) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + int paramIndex = 0; + + HeadersFrame headersFrame = new HeadersFrame(header); + + //check for PADDED flag and if set then store padding length + if (header.getFlags().contains(FrameFlag.PADDED)) { + int padLength = Utils.convertToInt(frameBody, paramIndex, 1); + if(padLength >= header.getLength()) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + headersFrame.setPadLength(padLength); + paramIndex += 1; + } + + //check for PRIORITY flag and if set then store the Exclusive bit (isExclusive) and store streamID + if (header.getFlags().contains(FrameFlag.PRIORITY)) { + + var streamId = Utils.convertToInt(frameBody, paramIndex, 4); + paramIndex += 4; + + if ((streamId & 0x80000000L) == 0x80000000L) { + headersFrame.setIsExclusive(true); + } + headersFrame.setDependentStream(streamId & 0x7FFFFFFFL); + if (headersFrame.dependentStreamId == header.getStreamIdentifier()) { + // cannot depend on itself + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + + headersFrame.setWeight( (frameBody[paramIndex] & 0xFF) +1 ); + paramIndex += 1; + } + + headersFrame.setHeaderBlock(Arrays.copyOfRange(frameBody, paramIndex, (header.getLength() - headersFrame.getPadLength()))); + + return headersFrame; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + byte[] buffer = getHeaderBlock(); + FrameHeader.writeTo(os, buffer.length, FrameType.HEADERS, FlagSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); + os.write(buffer); + os.flush(); + } + public byte[] encode() { + throw new UnsupportedOperationException("use HPackContext encodeFrameHeaders()"); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java new file mode 100644 index 0000000..ee64e7f --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java @@ -0,0 +1,21 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +class NotImplementedFrame extends BaseFrame { + private byte[] body; + public NotImplementedFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) { + return new NotImplementedFrame(frameHeader,body); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java new file mode 100644 index 0000000..d27a70d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java @@ -0,0 +1,46 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.EnumSet; +import java.util.List; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; + +public class PingFrame extends BaseFrame { + public final byte[] body; + + public PingFrame(FrameHeader header, byte[] body) { + super(header); + this.body = body; + } + public PingFrame() { + super(new FrameHeader(8,FrameType.PING,FrameFlag.NONE,0)); + body = new byte[8]; + } + public PingFrame(PingFrame toBeAcked) { + super(new FrameHeader(toBeAcked.body.length,FrameType.PING,FlagSet.of(FrameFlag.ACK),0)); + body = toBeAcked.body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + if (frameHeader.getStreamIdentifier() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + if(body.length!=8) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + return new PingFrame(frameHeader, body); + } + public byte[] encode() { + return Utils.combineByteArrays(List.of(getHeader().encode(),body)); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java new file mode 100644 index 0000000..5dfe343 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java @@ -0,0 +1,46 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class PriorityFrame extends BaseFrame { + public int streamDependency; + public int weight; + public boolean exclusive; + + public PriorityFrame(FrameHeader header) { + super(header); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws Exception { + var frame = new PriorityFrame(frameHeader); + if(body.length != 5) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + var tmp = Utils.convertToInt(body, 0, 4); + frame.exclusive = (tmp & 0x80000000) != 0; + frame.streamDependency = tmp & 0x7FFFFFFF; + if(frame.streamDependency == frameHeader.getStreamIdentifier()) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + frame.weight = (body[4] & 0xFF) + 1; + return frame; + } + + public byte[] encode() { + byte[] buffer = new byte[5]; + Utils.convertToBinary(buffer, 0, streamDependency); + buffer[4] = (byte)((weight-1) & 0xFF); + return Utils.combineByteArrays(List.of(getHeader().encode(),buffer)); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java new file mode 100644 index 0000000..2e6b38b --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java @@ -0,0 +1,20 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +public class PushPromiseFrame extends BaseFrame { + + public PushPromiseFrame(FrameHeader header) { + super(header); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) { + return new PushPromiseFrame(frameHeader); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java new file mode 100644 index 0000000..15f6289 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java @@ -0,0 +1,44 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.EnumSet; +import java.util.List; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; +import static robaho.net.httpserver.http2.Utils.convertToInt; + +public class ResetStreamFrame extends BaseFrame { + final public HTTP2ErrorCode errorCode; + + public ResetStreamFrame(FrameHeader header,HTTP2ErrorCode errorCode) { + super(header); + this.errorCode = errorCode; + } + public ResetStreamFrame(HTTP2ErrorCode errorCode,int streamId) { + super(new FrameHeader(4,FrameType.RST_STREAM,FrameFlag.NONE,streamId)); + this.errorCode = errorCode; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + Utils.writeBinary(os,errorCode.getValue(),4); + os.flush(); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception, Exception { + if(body.length != 4) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + return new ResetStreamFrame(frameHeader,HTTP2ErrorCode.getEnum(convertToInt(body, 0))); + } + public byte[] encode() { + byte[] buffer = new byte[4]; + Utils.convertToBinary(buffer, 0, errorCode.getValue()); + return Utils.combineByteArrays(List.of(getHeader().encode(),buffer)); + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java new file mode 100644 index 0000000..8cf03f7 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java @@ -0,0 +1,54 @@ +package robaho.net.httpserver.http2.frame; + +public enum SettingIdentifier { + SETTINGS_HEADER_TABLE_SIZE(0x1), + SETTINGS_ENABLE_PUSH(0x2), + SETTINGS_MAX_CONCURRENT_STREAMS(0x3), + SETTINGS_INITIAL_WINDOW_SIZE(0x4), + SETTINGS_MAX_FRAME_SIZE(0x5), + SETTINGS_MAX_HEADER_LIST_SIZE(0x6), + SETTINGS_NONE(0x0); // Not part of RFC // RFC + + int value; + + SettingIdentifier(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + static final SettingIdentifier[] _values = SettingIdentifier.values(); + + public static SettingIdentifier getEnum(int value) { + SettingIdentifier result = SettingIdentifier.SETTINGS_NONE; + + for (SettingIdentifier e : _values) { + if (e.getValue() == value) + result = e; + } + return result; + } + + public boolean validateValue(long value) { + switch (this) { + case SETTINGS_HEADER_TABLE_SIZE: + return true; + case SETTINGS_INITIAL_WINDOW_SIZE: + // hackish, but need to check in the application, since a different error must be thrown + return true; + case SETTINGS_MAX_FRAME_SIZE: + return value >= 16384 && value <= 16777215; + case SETTINGS_MAX_HEADER_LIST_SIZE: + return value >= 0; + case SETTINGS_ENABLE_PUSH: + return value == 0 || value == 1; + case SETTINGS_MAX_CONCURRENT_STREAMS: + return value >= 0 && value <= 0x7FFFFFFF; + default: + return false; + } + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java new file mode 100644 index 0000000..088b9eb --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java @@ -0,0 +1,52 @@ +package robaho.net.httpserver.http2.frame; +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class SettingParameter { + + static final int PARAMETER_SIZE = 6; + + public SettingIdentifier identifier; + public long value; + + public static SettingParameter DEFAULT_INITIAL_WINDOWSIZE = new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,65535); + + public SettingParameter() { + } + + public SettingParameter(SettingIdentifier identifier, long value) { + this.identifier = identifier; + this.value = value; + } + + public static SettingParameter parse(byte[] param) throws HTTP2Exception + { + try { + if(param.length != 6) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + SettingParameter result = new SettingParameter(); + result.identifier = SettingIdentifier.getEnum(Utils.convertToInt(param,0, 2)); + if(result.identifier == SettingIdentifier.SETTINGS_NONE) { + return null; + } + result.value = Utils.convertToLong(param, 2, 4); + if(!result.identifier.validateValue(result.value)) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Invalid value for setting "+result.identifier+" "+result.value); + } + return result; + } catch (HTTP2Exception ex) { + throw ex; + } catch (Exception ex) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,ex); + } + } + public void writeTo(OutputStream os) throws IOException { + Utils.writeBinary(os,identifier.getValue(), 2); + Utils.writeBinary(os,(int)value, 4); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java new file mode 100644 index 0000000..ba6666d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java @@ -0,0 +1,82 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; + +public class SettingsFrame extends BaseFrame { + + ArrayList params = new ArrayList<>(); + + /** + * SettingsFrame Constructor which calls the parameterized constructor + */ + public SettingsFrame() { + this(new FrameHeader(0, FrameType.SETTINGS, FlagSet.of(FrameFlag.ACK) , 0)); + } + + public SettingsFrame(FrameHeader header) { + super(header); + } + + public ArrayList getSettingParameters() { + return params; + } + + public static SettingsFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception { + if (header.getStreamIdentifier() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + + if(header.getFlags().contains(FrameFlag.ACK)) { + if(header.getLength() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + } + + if(frameBody.length % 6 != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + SettingsFrame result = new SettingsFrame(header); + int paramIndex = 0; + + while (paramIndex < frameBody.length) { + var param = SettingParameter.parse(Arrays.copyOfRange(frameBody, paramIndex, paramIndex + 6)); + if(param!=null) { + result.params.add(param); + } + paramIndex += 6; + } + return result; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + + int settingBodySize = params.size() * SettingParameter.PARAMETER_SIZE; + + FrameHeader header = new FrameHeader(settingBodySize,FrameType.SETTINGS,getHeader().getFlags(),getHeader().getStreamIdentifier()); + header.writeTo(os); + + for (int i = 0; i < params.size(); i++) { + params.get(i).writeTo(os); + } + } + + public byte[] encode() { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + writeTo(bos); + } catch (IOException ignore) { + } + return bos.toByteArray(); + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java new file mode 100644 index 0000000..6044315 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java @@ -0,0 +1,25 @@ +package robaho.net.httpserver.http2.frame; + +import java.util.function.Consumer; + +public class SettingsMap { + private final SettingParameter[] settings = new SettingParameter[SettingIdentifier._values.length]; + + public SettingParameter get(SettingIdentifier identifier) { + return settings[identifier.getValue()]; + } + public SettingParameter getOrDefault(SettingIdentifier identifier,SettingParameter defaultValue) { + SettingParameter setting = settings[identifier.getValue()]; + return setting == null ? defaultValue : setting; + } + public void set(SettingParameter setting) { + settings[setting.identifier.getValue()] = setting; + } + public void forEach(Consumer consumer) { + for (SettingParameter setting : settings) { + if (setting != null) { + consumer.accept(setting); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java new file mode 100644 index 0000000..31fa9e5 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java @@ -0,0 +1,58 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class WindowUpdateFrame extends BaseFrame { + + private int windowSizeIncrement; + + public WindowUpdateFrame(FrameHeader header) { + super(header); + } + public WindowUpdateFrame(int streamId,int increment) { + super(new FrameHeader(4,FrameType.WINDOW_UPDATE,FrameFlag.NONE,streamId)); + windowSizeIncrement = increment; + } + + public int getWindowSizeIncrement() + { + return windowSizeIncrement; + } + + public static WindowUpdateFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception { + + if(frameBody.length!=4) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + WindowUpdateFrame frame = new WindowUpdateFrame(header); + try { + frame.windowSizeIncrement = Utils.convertToInt(frameBody, 0); + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.INTERNAL_ERROR); + } + if(frame.windowSizeIncrement==0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"window size increment == 0"); + } + return frame; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + Utils.writeBinary(os, windowSizeIncrement); + } + + @Override + public byte[] encode() { + byte[] buffer = new byte[9+4]; + getHeader().encode(buffer); + Utils.convertToBinary(buffer,9,windowSizeIncrement); + return buffer; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java new file mode 100644 index 0000000..902edca --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -0,0 +1,470 @@ +package robaho.net.httpserver.http2.hpack; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +import java.util.List; +import java.util.Map; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.OpenAddressMap; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; +import robaho.net.httpserver.http2.frame.FrameType; + +public class HPackContext { + private final List dynamicTable = new ArrayList(1024); + + public HPackContext() { + } + + public HTTP2HeaderField getHeaderField(int index) { + if (index > 0 && index <= 61) { + return RFC7541Parser.getHeaderField(index); + } else { + return dynamicTable.get(index - 62); + } + } + + public void addHeaderField(HTTP2HeaderField field) { + dynamicTable.add(0,field); + } + + public List decodeFieldSegments(byte[] buffer) throws HTTP2Exception { + List headers = new ArrayList<>(8); + int index = 0; + + try { + + while (index < buffer.length) { + HTTP2HeaderField headerField = new HTTP2HeaderField(); + if ((buffer[index] & 0x80) != 0) { + index = decodeIndexedHeaderField(buffer, index, headerField); + } else if ((buffer[index] & 0x40) != 0) { + // Literal Header Field with Incremental Indexing + index = decodeFieldWithIncrementalIndexing(buffer, index, headerField); + } else if ((buffer[index] & 0xF0) == 0) { + // Literal Header Field without Indexing + index = decodeLiteralFieldWithoutIndexing(buffer, index, headerField); + } else if ((buffer[index] & 0xF0) == 0x10) { + // Literal Header Field never Indexed + index = decodeLiteralFieldNeverIndexed(buffer, index, headerField); + } else if((buffer[index] & 0xE0) == 0x20) { + if(!headers.isEmpty()) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Dynamic table size update must occur at beginning of block"); + } + index = decodeDynamicTableSizeUpdate(buffer,index); + continue; + } else { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header field representation " + buffer[index]); + } + headers.add(headerField); + } + } catch (HTTP2Exception e) { + throw e; + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, e); + } + + return headers; + } + + private int decodeIndexedHeaderField(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 7); + int headerIndex = pair.value; + index = pair.index; + + HTTP2HeaderField field = getHeaderField(headerIndex); + if (field == null) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex + ", dynamic: "+dynamicTable); + } + + headerField.setName(field.name,field.normalizedName); + headerField.setValue(field.value); + + return index; + } + + private int decodeFieldWithIncrementalIndexing(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 6); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + + dynamicTable.add(0,headerField); + + return index; + } + private int decodeLiteralFieldWithoutIndexing(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 4); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + return index; + } + private int decodeDynamicTableSizeUpdate(byte[] buffer, int index) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 5); + int size = pair.value; + index = pair.index; + + if (size > 4096) { // Assuming 4096 is the maximum size for the dynamic table + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Dynamic table size update too large: " + size); + } + + while (dynamicTable.size() > size) { + dynamicTable.removeLast(); + } + + return index; + } + + private int decodeFieldName(byte[] buffer, int index, int headerIndex, HTTP2HeaderField headerField) throws HTTP2Exception { + if (headerIndex == 0) { + boolean huffmanCode = (buffer[index] & 0x80) != 0; + int length = buffer[index] & 0x7F; + index++; + byte[] valueBytes = Arrays.copyOfRange(buffer, index, index + length); + index += length; + String value; + if (huffmanCode) { + value = Huffman.decode(valueBytes); + } else { + value = new String(valueBytes); + } + if(!value.equals(value.toLowerCase())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "header field name is not lowercase " + value); + } + headerField.setName(value); + } else { + var field = getHeaderField(headerIndex); + if (field == null) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex); + } + headerField.setName(field.name,field.normalizedName); + } + return index; + } + + private int decodeFieldValue(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + // Check if Huffman coding is used + boolean huffmanCode = (buffer[index] & 0x80) != 0; + var pair = decodeUnsignedInteger(buffer, index, 7); + index = pair.index; + int length = pair.value; + + byte[] valueBytes = Arrays.copyOfRange(buffer, index, index + length); + String value; + if (huffmanCode) { + value = Huffman.decode(valueBytes); + } else { + value = new String(valueBytes); + } + headerField.setValue(value); + + return index + length; + } + + private static record IndexValuePair(int index, int value) { + } + + private static IndexValuePair decodeUnsignedInteger(byte[] buffer, int index, int prefixBits) { + int value = buffer[index] & ((1 << prefixBits) - 1); + index++; + if (value < ((1 << prefixBits) - 1)) { + return new IndexValuePair(index, value); + } + + int m = 0; + int b; + do { + b = buffer[index] & 0xFF; + value += (b & 0x7F) << m; + m += 7; + index++; + } while ((b & 0x80) != 0); + + return new IndexValuePair(index, value); + } + + private int decodeLiteralFieldNeverIndexed(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 4); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + return index; + } + + /** this method is optimized for a server implementation and is not suitable for generic http2 hpack header encoding */ + public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId,boolean closeStream) throws IOException { + ByteArrayOutputStream fields = new ByteArrayOutputStream(256); // average http headers length is 800 bytes + + // ':status' is required and the only allowed outbound pseudo headers + fields.write(encodeHeader(":status", headers.getFirst(":status"))); + + StringBuilder sb = new StringBuilder(32); + + headers.forEach((name, values) -> { + for (String value : values) { + if(name.startsWith(":")) { + continue; + } + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + + sb.setLength(0); + sb.append(Character.toLowerCase(name.charAt(0))); + sb.append(name.substring(1)); + + byte[] header = encodeHeader(sb.toString(), value); + try { + fields.write(header); + } catch (IOException ex) { + } + } + }); + + FrameHeader.writeTo(outputStream, fields.size(),FrameType.HEADERS,closeStream ? END_OF_HEADERS_AND_STREAM : END_OF_HEADERS, streamId); + fields.writeTo(outputStream); + } + + public static void writeGenericHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { + ByteArrayOutputStream pseudo = new ByteArrayOutputStream(); + ByteArrayOutputStream fields = new ByteArrayOutputStream(); + + headers.forEach((name, values) -> { + for (String value : values) { + if(name.startsWith(":")) { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); + try { + pseudo.write(header); + } catch (IOException ex) { + } + } else { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); + try { + fields.write(header); + } catch (IOException ex) { + } + + } + } + }); + + FrameHeader.writeTo(outputStream,pseudo.size()+fields.size(),FrameType.HEADERS, END_OF_HEADERS, streamId); + pseudo.writeTo(outputStream); + fields.writeTo(outputStream); + } + + private static final FlagSet END_OF_HEADERS = FlagSet.of(FrameFlag.END_HEADERS); + private static final FlagSet END_OF_HEADERS_AND_STREAM = FlagSet.of(FrameFlag.END_HEADERS,FrameFlag.END_STREAM); + + public static List encodeHeadersFrame(Headers headers,int streamId) { + byte[] buffer = encodeHeaders(headers); + byte[] header = FrameHeader.encode(buffer.length, FrameType.HEADERS, END_OF_HEADERS, streamId); + return List.of(header,buffer); + } + + private static byte[] encodeHeaders(Headers headers) { + List fields = new ArrayList(headers.size()); + List pseudo = new ArrayList<>(6); + headers.forEach((name, values) -> { + for (String value : values) { + if(name.startsWith(":")) { + byte[] header = encodeHeader(name, value); + pseudo.add(header); + } else { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); + fields.add(header); + } + } + }); + return Utils.combineByteArrays(pseudo,fields); + } + + private static byte[] encodeHeader(String name, String value) { + if(":status".equals(name)) { + byte[] result = RFC7541Parser.STATUSES.get(value); + if(result!=null) return result; + } + var index = RFC7541Parser.getIndex(name); + + byte[] buffer = new byte[1]; + buffer[0]=0x00; // Literal Header Field without Indexing + byte[] name_buffer = null; + + if(index!=null) { + buffer = encodeIndexedField(index,4); + } else { + byte[] nameBytes = name.getBytes(); + name_buffer = encodeString(nameBytes); + } + + // Encode header value + byte[] valueBytes = value.getBytes(); + byte[] value_buffer = encodeString(valueBytes); + + return name_buffer == null ? Utils.combineByteArrays(buffer,value_buffer) : Utils.combineByteArrays(buffer,name_buffer,value_buffer); + } + + private static byte[] encodeString(byte[] value) { + // TODO: implement huffman encoding + byte[] buffer = new byte[0]; + if (value.length < 128) { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) value.length; + } else { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) (value.length | 0x80); + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) (value.length >> 7); + } + buffer = Arrays.copyOf(buffer, buffer.length + value.length); + System.arraycopy(value, 0, buffer, buffer.length - value.length, value.length); + return buffer; + } + + private static byte[] encodeIndexedField(int index, int prefixBits) { + byte[] buffer = new byte[1]; + int mask = (1 << prefixBits) - 1; + if (index < mask) { + buffer[0] = (byte) index; + return buffer; + } + buffer[0] = (byte) mask; + index -= mask; + while (index >= 128) { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) ((index & 0x7F) | 0x80); + index >>>= 7; + } + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) index; + return buffer; + } + +} + +class RFC7541Parser { + + private static final HTTP2HeaderField[] STATIC_HEADER_TABLE = new HTTP2HeaderField[62]; + static final Map STATUSES = Map.of( + "200",indexedField(8), + "204",indexedField(9), + "206",indexedField(10), + "304",indexedField(11), + "400",indexedField(12), + "404",indexedField(13), + "500",indexedField(13)); + + private static byte[] indexedField(int index) { + byte[] buffer = new byte[1]; + buffer[0] = (byte) (0x80 | index); + return buffer; + } + + static { + STATIC_HEADER_TABLE[1] = new HTTP2HeaderField(":authority", null); + STATIC_HEADER_TABLE[2] = new HTTP2HeaderField(":method", "GET"); + STATIC_HEADER_TABLE[3] = new HTTP2HeaderField(":method", "POST"); + STATIC_HEADER_TABLE[4] = new HTTP2HeaderField(":path", "/"); + STATIC_HEADER_TABLE[5] = new HTTP2HeaderField(":path", "/index.html"); + STATIC_HEADER_TABLE[6] = new HTTP2HeaderField(":scheme", "http"); + STATIC_HEADER_TABLE[7] = new HTTP2HeaderField(":scheme", "https"); + STATIC_HEADER_TABLE[8] = new HTTP2HeaderField(":status", "200"); + STATIC_HEADER_TABLE[9] = new HTTP2HeaderField(":status", "204"); + STATIC_HEADER_TABLE[10] = new HTTP2HeaderField(":status", "206"); + STATIC_HEADER_TABLE[11] = new HTTP2HeaderField(":status", "304"); + STATIC_HEADER_TABLE[12] = new HTTP2HeaderField(":status", "400"); + STATIC_HEADER_TABLE[13] = new HTTP2HeaderField(":status", "404"); + STATIC_HEADER_TABLE[14] = new HTTP2HeaderField(":status", "500"); + STATIC_HEADER_TABLE[15] = new HTTP2HeaderField("accept-charset", null); + STATIC_HEADER_TABLE[16] = new HTTP2HeaderField("accept-encoding", "gzip, deflate"); + STATIC_HEADER_TABLE[17] = new HTTP2HeaderField("accept-language", null); + STATIC_HEADER_TABLE[18] = new HTTP2HeaderField("accept-ranges", null); + STATIC_HEADER_TABLE[19] = new HTTP2HeaderField("accept", null); + STATIC_HEADER_TABLE[20] = new HTTP2HeaderField("access-control-allow-origin", null); + STATIC_HEADER_TABLE[21] = new HTTP2HeaderField("age", null); + STATIC_HEADER_TABLE[22] = new HTTP2HeaderField("allow", null); + STATIC_HEADER_TABLE[23] = new HTTP2HeaderField("authorization", null); + STATIC_HEADER_TABLE[24] = new HTTP2HeaderField("cache-control", null); + STATIC_HEADER_TABLE[25] = new HTTP2HeaderField("content-disposition", null); + STATIC_HEADER_TABLE[26] = new HTTP2HeaderField("content-encoding", null); + STATIC_HEADER_TABLE[27] = new HTTP2HeaderField("content-language", null); + STATIC_HEADER_TABLE[28] = new HTTP2HeaderField("content-length", null); + STATIC_HEADER_TABLE[29] = new HTTP2HeaderField("content-location", null); + STATIC_HEADER_TABLE[30] = new HTTP2HeaderField("content-range", null); + STATIC_HEADER_TABLE[31] = new HTTP2HeaderField("content-type", null); + STATIC_HEADER_TABLE[32] = new HTTP2HeaderField("cookie", null); + STATIC_HEADER_TABLE[33] = new HTTP2HeaderField("date", null); + STATIC_HEADER_TABLE[34] = new HTTP2HeaderField("etag", null); + STATIC_HEADER_TABLE[35] = new HTTP2HeaderField("expect", null); + STATIC_HEADER_TABLE[36] = new HTTP2HeaderField("expires", null); + STATIC_HEADER_TABLE[37] = new HTTP2HeaderField("from", null); + STATIC_HEADER_TABLE[38] = new HTTP2HeaderField("host", null); + STATIC_HEADER_TABLE[39] = new HTTP2HeaderField("if-match", null); + STATIC_HEADER_TABLE[40] = new HTTP2HeaderField("if-modified-since", null); + STATIC_HEADER_TABLE[41] = new HTTP2HeaderField("if-none-match", null); + STATIC_HEADER_TABLE[42] = new HTTP2HeaderField("if-range", null); + STATIC_HEADER_TABLE[43] = new HTTP2HeaderField("if-unmodified-since", null); + STATIC_HEADER_TABLE[44] = new HTTP2HeaderField("last-modified", null); + STATIC_HEADER_TABLE[45] = new HTTP2HeaderField("link", null); + STATIC_HEADER_TABLE[46] = new HTTP2HeaderField("location", null); + STATIC_HEADER_TABLE[47] = new HTTP2HeaderField("max-forwards", null); + STATIC_HEADER_TABLE[48] = new HTTP2HeaderField("proxy-authenticate", null); + STATIC_HEADER_TABLE[49] = new HTTP2HeaderField("proxy-authorization", null); + STATIC_HEADER_TABLE[50] = new HTTP2HeaderField("range", null); + STATIC_HEADER_TABLE[51] = new HTTP2HeaderField("referer", null); + STATIC_HEADER_TABLE[52] = new HTTP2HeaderField("refresh", null); + STATIC_HEADER_TABLE[53] = new HTTP2HeaderField("retry-after", null); + STATIC_HEADER_TABLE[54] = new HTTP2HeaderField("server", null); + STATIC_HEADER_TABLE[55] = new HTTP2HeaderField("set-cookie", null); + STATIC_HEADER_TABLE[56] = new HTTP2HeaderField("strict-transport-security", null); + STATIC_HEADER_TABLE[57] = new HTTP2HeaderField("transfer-encoding", null); + STATIC_HEADER_TABLE[58] = new HTTP2HeaderField("user-agent", null); + STATIC_HEADER_TABLE[59] = new HTTP2HeaderField("vary", null); + STATIC_HEADER_TABLE[60] = new HTTP2HeaderField("via", null); + STATIC_HEADER_TABLE[61] = new HTTP2HeaderField("www-authenticate", null); + } + + private static final OpenAddressMap STATIC_HEADER_NAME_TO_INDEX = new OpenAddressMap<>(256); + static { + Arrays.stream(STATIC_HEADER_TABLE).filter(v -> v!=null).forEach(v -> STATIC_HEADER_NAME_TO_INDEX.put(v.name, Arrays.asList(STATIC_HEADER_TABLE).indexOf(v))); + } + + public static Integer getIndex(String name) { + return STATIC_HEADER_NAME_TO_INDEX.get(name); + } + + public static HTTP2HeaderField getHeaderField(int index) { + if (index < 1 || index >= STATIC_HEADER_TABLE.length) { + return null; + } + return STATIC_HEADER_TABLE[index]; + } + + public static String getHeaderFieldName(int index) { + var field = getHeaderField(index); + return field == null ? null : field.getName(); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java new file mode 100644 index 0000000..4e01d42 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java @@ -0,0 +1,54 @@ +package robaho.net.httpserver.http2.hpack; + +public class HTTP2HeaderField { + + public String name; + public String value; + public String normalizedName; + + public HTTP2HeaderField() { + } + + public HTTP2HeaderField(String name, String value) { + this.name = name; + this.value = value; + this.normalizedName = normalize(name); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + this.normalizedName = normalize(name); + } + public void setName(String name,String normalizedName) { + this.name = name; + this.normalizedName = normalizedName; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + return name + ": " + value; + } + + public boolean isPseudoHeader() { + return name.startsWith(":"); + } + + public static String normalize(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return Character.toUpperCase(value.charAt(0)) + value.substring(1); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java new file mode 100644 index 0000000..fc61e56 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java @@ -0,0 +1,83 @@ +package robaho.net.httpserver.http2.hpack; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import robaho.net.httpserver.BloomSet; +import robaho.net.httpserver.OpenAddressMap; +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +/** + * container for emitted HTTP2HeaderField from field block decoding. Many of the rules of valid + * headers require multi-field inspection, so aggregating and validating at the end is an easier + * design + */ +public class HeaderFields implements Iterable { + private static final BloomSet prohibitedHeaderFields = BloomSet.of("connection"); + private static final BloomSet requiredHeaderFields = BloomSet.of(":path",":method",":scheme"); + private static final BloomSet pseudoHeadersIn = BloomSet.of(":authority", ":method", ":path", ":scheme"); + + private final List fields = new ArrayList(); + private final OpenAddressMap pseudoHeaders = new OpenAddressMap(8); + + private boolean hasNonPseudoHeader = false; + + /** + * add a HTTP2HeaderField to the collection performing any per field validation + */ + public void addHeaderField(HTTP2HeaderField field) throws HTTP2Exception{ + if(field.isPseudoHeader() && !pseudoHeadersIn.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "invalid pseudo header " + field.getName()); + } + if(prohibitedHeaderFields.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"prohibited header field "+field.getName()); + } + if(field.getName().equals("te") && !field.getValue().equals("trailers")) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"prohibited header field "+field.getName()); + } + if(!field.isPseudoHeader()) { + hasNonPseudoHeader = true; + } + if(field.isPseudoHeader() && hasNonPseudoHeader) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"pseudo-header fields must appear before regular header fields"); + } + if(field.isPseudoHeader()) { + if(pseudoHeaders.put(field.getName(),field)!=null && requiredHeaderFields.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"invalid duplicate header "+field.getName()); + } + } + fields.add(field); + } + private static boolean isEmpty(String s) { + return s == null || s.trim().equals(""); + } + public void addAll(List httpFields) throws HTTP2Exception { + for(HTTP2HeaderField field : httpFields) { + addHeaderField(field); + } + } + @Override + public Iterator iterator() { + return fields.iterator(); + } + public void clear() { + fields.clear(); + hasNonPseudoHeader = false; + } + /** + * perform the multi-field validation of the collection of header fields + */ + public void validate() throws HTTP2Exception { + for(var fieldName : requiredHeaderFields.values()) { + var ph = pseudoHeaders.get(fieldName); + if(ph==null || isEmpty(((HTTP2HeaderField)ph).getValue())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"missing required header field "+fieldName); + } + } + } + public int size() { + return fields.size(); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java new file mode 100644 index 0000000..3824950 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java @@ -0,0 +1,233 @@ +package robaho.net.httpserver.http2.hpack; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URISyntaxException; +import java.util.Arrays; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class Huffman { + private static class HuffmanSequence implements Comparable { + private final char[] buffer; + private final int length; + private final int hash; + HuffmanSequence(char[] buffer, int length) { + this.buffer = buffer; + this.length = length; + this.hash = calculateHash(); + } + HuffmanSequence(char[] buffer) { + this(buffer, buffer.length); + } + private int calculateHash() { + int hash = 0; + for (int i = 0; i < length; i++) { + hash = 31 * hash + (buffer[i] == '1' ? 1 : 0); + } + return hash; + } + @Override + public int hashCode() { + return hash; + } + @Override + public boolean equals(Object obj) { + if (!(obj instanceof HuffmanSequence other)) { + return false; + } + if (hash != other.hash) { + return false; + } + return compareTo(other) == 0; + } + @Override + public String toString() { + return new String(buffer, 0, length); + } + @Override + public int compareTo(HuffmanSequence o) { + if (length != o.length) { + return length - o.length; + } + for (int i = 0; i < length; i++) { + if (buffer[i] != o.buffer[i]) { + return buffer[i] - o.buffer[i]; + } + } + return 0; + } + } + private static class HuffmanCode { + private final HuffmanSequence sequence; + private final int value; + HuffmanCode(HuffmanSequence sequence, int value) { + this.sequence = sequence; + this.value = value; + } + } + /** + * a skip list of huffman codes + */ + private static class HuffmanCodes { + private final HuffmanCode[] codes; + /** + * holds the offset into the array for the start of codes by the code length, or -1 if there + * are no codes of that length + */ + private final int[] offsets = new int[33]; + + HuffmanCodes(HuffmanCode[] codes) { + this.codes = codes; + Arrays.sort(codes, (a, b) -> a.sequence.compareTo(b.sequence)); + Arrays.fill(offsets,-1); + for (int i = 0; i < codes.length; i++) { + HuffmanSequence sequence = codes[i].sequence; + int length = sequence.length; + if (length <= 32 && offsets[length] == -1) { + offsets[length] = i; + } + } + } + /** @return the matched character value or null if no match */ + Integer get(HuffmanSequence sequence) { + int index = offsets[sequence.length]; + if (index == -1) { + return null; + } + while(index+8 < codes.length && codes[index+8].sequence.compareTo(sequence)<0) { + index+=8; + } + while(index+4 < codes.length && codes[index+4].sequence.compareTo(sequence)<0) { + index+=4; + } + int result=0; + while(index < codes.length && (result = codes[index].sequence.compareTo(sequence))<0) { + index++; + } + if(index < codes.length && result==0) { + return codes[index].value; + } else { + return null; + } + } + } + + private static HuffmanCodes huffmanCodes; + private static HuffmanCodes getHuffmanCodes() + throws FileNotFoundException, IOException, URISyntaxException { + if (huffmanCodes == null) { + HuffmanCode[] codes = new HuffmanCode[257]; + + ClassLoader classloader = Thread.currentThread().getContextClassLoader(); + InputStream is = classloader.getResourceAsStream("huffman_codes_rfc7541.txt"); + + try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { + String line; + String code; + int value = 0; + while ((line = br.readLine()) != null) { + code = line.substring(11, line.indexOf(' ', 11)); + code = code.replace("|", ""); + codes[value] = new HuffmanCode(new HuffmanSequence(code.toCharArray()), value); + value++; + } + } + huffmanCodes = new HuffmanCodes(codes); + } + + return huffmanCodes; + } + + public static String decode(byte[] value) throws HTTP2Exception { + StringBuilder result = new StringBuilder(); + + HuffmanCodes codes; + try { + codes = getHuffmanCodes(); + } catch (IOException ex) { + throw new HTTP2Exception("Error reading huffman codes", ex); + } catch (URISyntaxException ex) { + throw new HTTP2Exception("Error reading huffman codes", ex); + } + + CodeBuffer code = new CodeBuffer(); + + for (int i = 0; i < value.length; i++) { + int unsignedByte = value[i] & 0xff; + for (int j = 0; j < 8; j++) { + if ((unsignedByte & 0x00000080) != 0) { + code.append('1'); + } else { + code.append('0'); + } + + unsignedByte = unsignedByte << 1; + + var intValue = codes.get(code.sequence()); + if(intValue!=null) { + if(intValue==256) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded contains EOS "+code); + } + result.append((char)(intValue & 0xFF)); + code.reset(); + } + } + } + + // Check for EOS (End of Stream) condition + if (code.length() > 7 || code.indexOf('0')>=0) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded has incorrect padding "+code); + } + + return result.toString(); + } + private static class CodeBuffer { + private char[] buffer = new char[32]; + private int length; + + void append(char ch) { + if (length >= buffer.length) { + char[] newBuffer = new char[buffer.length * 2]; + System.arraycopy(buffer, 0, newBuffer, 0, buffer.length); + buffer = newBuffer; + } + buffer[length++] = ch; + } + + void reset() { + length = 0; + } + + int length() { + return length; + } + + int indexOf(char c) { + for (int i = 0; i < length; i++) { + if (buffer[i] == c) { + return i; + } + } + return -1; + } + + HuffmanSequence sequence() { + return new HuffmanSequence(buffer, length); + } + + @Override + public String toString() { + return new String(buffer, 0, length); + } + } + + + + + +} diff --git a/src/main/java/robaho/net/httpserver/websockets/WebSocket.java b/src/main/java/robaho/net/httpserver/websockets/WebSocket.java index 7317c4b..ae9d9da 100644 --- a/src/main/java/robaho/net/httpserver/websockets/WebSocket.java +++ b/src/main/java/robaho/net/httpserver/websockets/WebSocket.java @@ -43,14 +43,14 @@ import java.util.List; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; -import java.util.logging.Level; -import java.util.logging.Logger; import com.sun.net.httpserver.HttpExchange; +import static java.lang.System.Logger.Level.*; + public abstract class WebSocket { - Logger logger = Logger.getLogger(WebSocket.class.getName()); + protected final System.Logger logger = System.getLogger("robaho.net.httpserver.websockets"); private final InputStream in; private final OutputStream out; @@ -68,7 +68,7 @@ public abstract class WebSocket { protected WebSocket(HttpExchange exchange) { this.uri = exchange.getRequestURI(); - logger.info("connecting websocket "+uri); + logger.log(INFO, "connecting websocket {0}", uri); this.state = State.CONNECTING; this.in = exchange.getRequestBody(); @@ -90,12 +90,12 @@ protected void onOpen() throws WebSocketException { protected void onException(IOException exception) { if(state!=State.CLOSING && state!=State.CLOSED) { - logger.log(Level.FINER, "exception on websocket", exception); + logger.log(TRACE, "exception on websocket", exception); } } protected void onFrameReceived(WebSocketFrame frame) { - logger.log(Level.FINER, () -> "frame received: " + frame); + logger.log(TRACE, "frame received: {0}", frame); } /** @@ -106,11 +106,11 @@ protected void onFrameReceived(WebSocketFrame frame) { * The sent WebSocket Frame. */ protected void onFrameSent(WebSocketFrame frame) { - logger.log(Level.FINER, () -> "frame sent: " + frame); + logger.log(TRACE, "frame sent: {0}", frame); } public void close(CloseCode code, String reason, boolean initiatedByRemote) throws IOException { - logger.info("closing websocket "+uri); + logger.log(INFO, "closing websocket {0}", uri); State oldState = this.state; this.state = State.CLOSING; @@ -150,7 +150,7 @@ private void handleCloseFrame(WebSocketFrame frame) throws IOException { code = ((CloseFrame) frame).getCloseCode(); reason = ((CloseFrame) frame).getCloseReason(); } - logger.finest("handleCloseFrame: "+uri+", code="+code+", reason="+reason+", state "+this.state); + logger.log(TRACE, "handleCloseFrame: {0}, code={1}, reason={2}, state {3}", uri, code, reason, this.state); if (this.state == State.CLOSING) { // Answer for my requested close doClose(code, reason, false); @@ -215,7 +215,7 @@ public void ping(byte[] payload) throws IOException { void readWebsocket() { try { state = State.OPEN; - logger.fine("websocket open "+uri); + logger.log(DEBUG, "websocket open {0}", uri); onOpen(); while (this.state == State.OPEN) { handleWebsocketFrame(WebSocketFrame.read(in)); @@ -235,7 +235,7 @@ void readWebsocket() { } } finally { doClose(CloseCode.InternalServerError, "Handler terminated without closing the connection.", false); - logger.finest("readWebsocket() exiting "+uri); + logger.log(TRACE, "readWebsocket() exiting {0}", uri); } } diff --git a/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java b/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java index 434b19e..db6640a 100644 --- a/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java +++ b/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java @@ -8,6 +8,7 @@ import com.sun.net.httpserver.HttpHandler; import robaho.net.httpserver.Code; +import robaho.net.httpserver.Utils; public abstract class WebSocketHandler implements HttpHandler { @@ -58,11 +59,9 @@ public void handle(HttpExchange exchange) throws IOException { public static boolean isWebsocketRequested(Headers headers) { // check if Upgrade connection - String connection = headers.getFirst(Util.HEADER_CONNECTION); - if (connection == null || !connection.equalsIgnoreCase(Util.HEADER_CONNECTION_VALUE)) { - return false; - } - // check for proper upgrade tyoe + var values = headers.get(Util.HEADER_CONNECTION); + if(values==null || values.stream().filter(s -> Utils.containsIgnoreCase(s, Util.HEADER_CONNECTION_VALUE)).findAny().isEmpty()) return false; + // check for proper upgrade type String upgrade = headers.getFirst(Util.HEADER_UPGRADE); return Util.HEADER_UPGRADE_VALUE.equalsIgnoreCase(upgrade); } diff --git a/src/main/resources/huffman_codes_rfc7541.txt b/src/main/resources/huffman_codes_rfc7541.txt new file mode 100644 index 0000000..f4091a0 --- /dev/null +++ b/src/main/resources/huffman_codes_rfc7541.txt @@ -0,0 +1,257 @@ + ( 0) |11111111|11000 1ff8 [13] + ( 1) |11111111|11111111|1011000 7fffd8 [23] + ( 2) |11111111|11111111|11111110|0010 fffffe2 [28] + ( 3) |11111111|11111111|11111110|0011 fffffe3 [28] + ( 4) |11111111|11111111|11111110|0100 fffffe4 [28] + ( 5) |11111111|11111111|11111110|0101 fffffe5 [28] + ( 6) |11111111|11111111|11111110|0110 fffffe6 [28] + ( 7) |11111111|11111111|11111110|0111 fffffe7 [28] + ( 8) |11111111|11111111|11111110|1000 fffffe8 [28] + ( 9) |11111111|11111111|11101010 ffffea [24] + ( 10) |11111111|11111111|11111111|111100 3ffffffc [30] + ( 11) |11111111|11111111|11111110|1001 fffffe9 [28] + ( 12) |11111111|11111111|11111110|1010 fffffea [28] + ( 13) |11111111|11111111|11111111|111101 3ffffffd [30] + ( 14) |11111111|11111111|11111110|1011 fffffeb [28] + ( 15) |11111111|11111111|11111110|1100 fffffec [28] + ( 16) |11111111|11111111|11111110|1101 fffffed [28] + ( 17) |11111111|11111111|11111110|1110 fffffee [28] + ( 18) |11111111|11111111|11111110|1111 fffffef [28] + ( 19) |11111111|11111111|11111111|0000 ffffff0 [28] + ( 20) |11111111|11111111|11111111|0001 ffffff1 [28] + ( 21) |11111111|11111111|11111111|0010 ffffff2 [28] + ( 22) |11111111|11111111|11111111|111110 3ffffffe [30] + ( 23) |11111111|11111111|11111111|0011 ffffff3 [28] + ( 24) |11111111|11111111|11111111|0100 ffffff4 [28] + ( 25) |11111111|11111111|11111111|0101 ffffff5 [28] + ( 26) |11111111|11111111|11111111|0110 ffffff6 [28] + ( 27) |11111111|11111111|11111111|0111 ffffff7 [28] + ( 28) |11111111|11111111|11111111|1000 ffffff8 [28] + ( 29) |11111111|11111111|11111111|1001 ffffff9 [28] + ( 30) |11111111|11111111|11111111|1010 ffffffa [28] + ( 31) |11111111|11111111|11111111|1011 ffffffb [28] +' ' ( 32) |010100 14 [ 6] +'!' ( 33) |11111110|00 3f8 [10] +'"' ( 34) |11111110|01 3f9 [10] +'#' ( 35) |11111111|1010 ffa [12] +'$' ( 36) |11111111|11001 1ff9 [13] +'%' ( 37) |010101 15 [ 6] +'&' ( 38) |11111000 f8 [ 8] +''' ( 39) |11111111|010 7fa [11] +'(' ( 40) |11111110|10 3fa [10] +')' ( 41) |11111110|11 3fb [10] +'*' ( 42) |11111001 f9 [ 8] +'+' ( 43) |11111111|011 7fb [11] +',' ( 44) |11111010 fa [ 8] +'-' ( 45) |010110 16 [ 6] +'.' ( 46) |010111 17 [ 6] +'/' ( 47) |011000 18 [ 6] +'0' ( 48) |00000 0 [ 5] +'1' ( 49) |00001 1 [ 5] +'2' ( 50) |00010 2 [ 5] +'3' ( 51) |011001 19 [ 6] +'4' ( 52) |011010 1a [ 6] +'5' ( 53) |011011 1b [ 6] +'6' ( 54) |011100 1c [ 6] +'7' ( 55) |011101 1d [ 6] +'8' ( 56) |011110 1e [ 6] +'9' ( 57) |011111 1f [ 6] +':' ( 58) |1011100 5c [ 7] +';' ( 59) |11111011 fb [ 8] +'<' ( 60) |11111111|1111100 7ffc [15] +'=' ( 61) |100000 20 [ 6] +'>' ( 62) |11111111|1011 ffb [12] +'?' ( 63) |11111111|00 3fc [10] +'@' ( 64) |11111111|11010 1ffa [13] +'A' ( 65) |100001 21 [ 6] +'B' ( 66) |1011101 5d [ 7] +'C' ( 67) |1011110 5e [ 7] +'D' ( 68) |1011111 5f [ 7] +'E' ( 69) |1100000 60 [ 7] +'F' ( 70) |1100001 61 [ 7] +'G' ( 71) |1100010 62 [ 7] +'H' ( 72) |1100011 63 [ 7] +'I' ( 73) |1100100 64 [ 7] +'J' ( 74) |1100101 65 [ 7] +'K' ( 75) |1100110 66 [ 7] +'L' ( 76) |1100111 67 [ 7] +'M' ( 77) |1101000 68 [ 7] +'N' ( 78) |1101001 69 [ 7] +'O' ( 79) |1101010 6a [ 7] +'P' ( 80) |1101011 6b [ 7] +'Q' ( 81) |1101100 6c [ 7] +'R' ( 82) |1101101 6d [ 7] +'S' ( 83) |1101110 6e [ 7] +'T' ( 84) |1101111 6f [ 7] +'U' ( 85) |1110000 70 [ 7] +'V' ( 86) |1110001 71 [ 7] +'W' ( 87) |1110010 72 [ 7] +'X' ( 88) |11111100 fc [ 8] +'Y' ( 89) |1110011 73 [ 7] +'Z' ( 90) |11111101 fd [ 8] +'[' ( 91) |11111111|11011 1ffb [13] +'\' ( 92) |11111111|11111110|000 7fff0 [19] +']' ( 93) |11111111|11100 1ffc [13] +'^' ( 94) |11111111|111100 3ffc [14] +'_' ( 95) |100010 22 [ 6] +'`' ( 96) |11111111|1111101 7ffd [15] +'a' ( 97) |00011 3 [ 5] +'b' ( 98) |100011 23 [ 6] +'c' ( 99) |00100 4 [ 5] +'d' (100) |100100 24 [ 6] +'e' (101) |00101 5 [ 5] +'f' (102) |100101 25 [ 6] +'g' (103) |100110 26 [ 6] +'h' (104) |100111 27 [ 6] +'i' (105) |00110 6 [ 5] +'j' (106) |1110100 74 [ 7] +'k' (107) |1110101 75 [ 7] +'l' (108) |101000 28 [ 6] +'m' (109) |101001 29 [ 6] +'n' (110) |101010 2a [ 6] +'o' (111) |00111 7 [ 5] +'p' (112) |101011 2b [ 6] +'q' (113) |1110110 76 [ 7] +'r' (114) |101100 2c [ 6] +'s' (115) |01000 8 [ 5] +'t' (116) |01001 9 [ 5] +'u' (117) |101101 2d [ 6] +'v' (118) |1110111 77 [ 7] +'w' (119) |1111000 78 [ 7] +'x' (120) |1111001 79 [ 7] +'y' (121) |1111010 7a [ 7] +'z' (122) |1111011 7b [ 7] +'{' (123) |11111111|1111110 7ffe [15] +'|' (124) |11111111|100 7fc [11] +'}' (125) |11111111|111101 3ffd [14] +'~' (126) |11111111|11101 1ffd [13] + (127) |11111111|11111111|11111111|1100 ffffffc [28] + (128) |11111111|11111110|0110 fffe6 [20] + (129) |11111111|11111111|010010 3fffd2 [22] + (130) |11111111|11111110|0111 fffe7 [20] + (131) |11111111|11111110|1000 fffe8 [20] + (132) |11111111|11111111|010011 3fffd3 [22] + (133) |11111111|11111111|010100 3fffd4 [22] + (134) |11111111|11111111|010101 3fffd5 [22] + (135) |11111111|11111111|1011001 7fffd9 [23] + (136) |11111111|11111111|010110 3fffd6 [22] + (137) |11111111|11111111|1011010 7fffda [23] + (138) |11111111|11111111|1011011 7fffdb [23] + (139) |11111111|11111111|1011100 7fffdc [23] + (140) |11111111|11111111|1011101 7fffdd [23] + (141) |11111111|11111111|1011110 7fffde [23] + (142) |11111111|11111111|11101011 ffffeb [24] + (143) |11111111|11111111|1011111 7fffdf [23] + (144) |11111111|11111111|11101100 ffffec [24] + (145) |11111111|11111111|11101101 ffffed [24] + (146) |11111111|11111111|010111 3fffd7 [22] + (147) |11111111|11111111|1100000 7fffe0 [23] + (148) |11111111|11111111|11101110 ffffee [24] + (149) |11111111|11111111|1100001 7fffe1 [23] + (150) |11111111|11111111|1100010 7fffe2 [23] + (151) |11111111|11111111|1100011 7fffe3 [23] + (152) |11111111|11111111|1100100 7fffe4 [23] + (153) |11111111|11111110|11100 1fffdc [21] + (154) |11111111|11111111|011000 3fffd8 [22] + (155) |11111111|11111111|1100101 7fffe5 [23] + (156) |11111111|11111111|011001 3fffd9 [22] + (157) |11111111|11111111|1100110 7fffe6 [23] + (158) |11111111|11111111|1100111 7fffe7 [23] + (159) |11111111|11111111|11101111 ffffef [24] + (160) |11111111|11111111|011010 3fffda [22] + (161) |11111111|11111110|11101 1fffdd [21] + (162) |11111111|11111110|1001 fffe9 [20] + (163) |11111111|11111111|011011 3fffdb [22] + (164) |11111111|11111111|011100 3fffdc [22] + (165) |11111111|11111111|1101000 7fffe8 [23] + (166) |11111111|11111111|1101001 7fffe9 [23] + (167) |11111111|11111110|11110 1fffde [21] + (168) |11111111|11111111|1101010 7fffea [23] + (169) |11111111|11111111|011101 3fffdd [22] + (170) |11111111|11111111|011110 3fffde [22] + (171) |11111111|11111111|11110000 fffff0 [24] + (172) |11111111|11111110|11111 1fffdf [21] + (173) |11111111|11111111|011111 3fffdf [22] + (174) |11111111|11111111|1101011 7fffeb [23] + (175) |11111111|11111111|1101100 7fffec [23] + (176) |11111111|11111111|00000 1fffe0 [21] + (177) |11111111|11111111|00001 1fffe1 [21] + (178) |11111111|11111111|100000 3fffe0 [22] + (179) |11111111|11111111|00010 1fffe2 [21] + (180) |11111111|11111111|1101101 7fffed [23] + (181) |11111111|11111111|100001 3fffe1 [22] + (182) |11111111|11111111|1101110 7fffee [23] + (183) |11111111|11111111|1101111 7fffef [23] + (184) |11111111|11111110|1010 fffea [20] + (185) |11111111|11111111|100010 3fffe2 [22] + (186) |11111111|11111111|100011 3fffe3 [22] + (187) |11111111|11111111|100100 3fffe4 [22] + (188) |11111111|11111111|1110000 7ffff0 [23] + (189) |11111111|11111111|100101 3fffe5 [22] + (190) |11111111|11111111|100110 3fffe6 [22] + (191) |11111111|11111111|1110001 7ffff1 [23] + (192) |11111111|11111111|11111000|00 3ffffe0 [26] + (193) |11111111|11111111|11111000|01 3ffffe1 [26] + (194) |11111111|11111110|1011 fffeb [20] + (195) |11111111|11111110|001 7fff1 [19] + (196) |11111111|11111111|100111 3fffe7 [22] + (197) |11111111|11111111|1110010 7ffff2 [23] + (198) |11111111|11111111|101000 3fffe8 [22] + (199) |11111111|11111111|11110110|0 1ffffec [25] + (200) |11111111|11111111|11111000|10 3ffffe2 [26] + (201) |11111111|11111111|11111000|11 3ffffe3 [26] + (202) |11111111|11111111|11111001|00 3ffffe4 [26] + (203) |11111111|11111111|11111011|110 7ffffde [27] + (204) |11111111|11111111|11111011|111 7ffffdf [27] + (205) |11111111|11111111|11111001|01 3ffffe5 [26] + (206) |11111111|11111111|11110001 fffff1 [24] + (207) |11111111|11111111|11110110|1 1ffffed [25] + (208) |11111111|11111110|010 7fff2 [19] + (209) |11111111|11111111|00011 1fffe3 [21] + (210) |11111111|11111111|11111001|10 3ffffe6 [26] + (211) |11111111|11111111|11111100|000 7ffffe0 [27] + (212) |11111111|11111111|11111100|001 7ffffe1 [27] + (213) |11111111|11111111|11111001|11 3ffffe7 [26] + (214) |11111111|11111111|11111100|010 7ffffe2 [27] + (215) |11111111|11111111|11110010 fffff2 [24] + (216) |11111111|11111111|00100 1fffe4 [21] + (217) |11111111|11111111|00101 1fffe5 [21] + (218) |11111111|11111111|11111010|00 3ffffe8 [26] + (219) |11111111|11111111|11111010|01 3ffffe9 [26] + (220) |11111111|11111111|11111111|1101 ffffffd [28] + (221) |11111111|11111111|11111100|011 7ffffe3 [27] + (222) |11111111|11111111|11111100|100 7ffffe4 [27] + (223) |11111111|11111111|11111100|101 7ffffe5 [27] + (224) |11111111|11111110|1100 fffec [20] + (225) |11111111|11111111|11110011 fffff3 [24] + (226) |11111111|11111110|1101 fffed [20] + (227) |11111111|11111111|00110 1fffe6 [21] + (228) |11111111|11111111|101001 3fffe9 [22] + (229) |11111111|11111111|00111 1fffe7 [21] + (230) |11111111|11111111|01000 1fffe8 [21] + (231) |11111111|11111111|1110011 7ffff3 [23] + (232) |11111111|11111111|101010 3fffea [22] + (233) |11111111|11111111|101011 3fffeb [22] + (234) |11111111|11111111|11110111|0 1ffffee [25] + (235) |11111111|11111111|11110111|1 1ffffef [25] + (236) |11111111|11111111|11110100 fffff4 [24] + (237) |11111111|11111111|11110101 fffff5 [24] + (238) |11111111|11111111|11111010|10 3ffffea [26] + (239) |11111111|11111111|1110100 7ffff4 [23] + (240) |11111111|11111111|11111010|11 3ffffeb [26] + (241) |11111111|11111111|11111100|110 7ffffe6 [27] + (242) |11111111|11111111|11111011|00 3ffffec [26] + (243) |11111111|11111111|11111011|01 3ffffed [26] + (244) |11111111|11111111|11111100|111 7ffffe7 [27] + (245) |11111111|11111111|11111101|000 7ffffe8 [27] + (246) |11111111|11111111|11111101|001 7ffffe9 [27] + (247) |11111111|11111111|11111101|010 7ffffea [27] + (248) |11111111|11111111|11111101|011 7ffffeb [27] + (249) |11111111|11111111|11111111|1110 ffffffe [28] + (250) |11111111|11111111|11111101|100 7ffffec [27] + (251) |11111111|11111111|11111101|101 7ffffed [27] + (252) |11111111|11111111|11111101|110 7ffffee [27] + (253) |11111111|11111111|11111101|111 7ffffef [27] + (254) |11111111|11111111|11111110|000 7fffff0 [27] + (255) |11111111|11111111|11111011|10 3ffffee [26] +EOS (256) |11111111|11111111|11111111|111111 3fffffff [30] \ No newline at end of file diff --git a/src/test/extras/SimpleFileServer.java b/src/test/extras/SimpleFileServer.java index 7dfc7a1..7d0684b 100644 --- a/src/test/extras/SimpleFileServer.java +++ b/src/test/extras/SimpleFileServer.java @@ -28,6 +28,7 @@ import com.sun.net.httpserver.*; +import jdk.test.lib.net.SimpleSSLContext; import robaho.net.httpserver.LogFilter; import robaho.net.httpserver.extras.ContentEncoding; import robaho.net.httpserver.extras.QueryParameters; @@ -48,7 +49,7 @@ public class SimpleFileServer { public static void main(String[] args) throws Exception { if (args.length != 3) { - System.out.println("usage: java FileServerHandler rootDir port logfilename"); + System.out.println("usage: java SimpleFileServer rootDir port logfilename"); System.exit(1); } Logger logger = Logger.getLogger("com.sun.net.httpserver"); @@ -57,13 +58,24 @@ public static void main(String[] args) throws Exception { ch.setLevel(Level.ALL); logger.addHandler(ch); + logger.log(Level.INFO,() -> "using Java version: " + System.getProperty("java.version")); + String rootDir = args[0]; int port = Integer.parseInt(args[1]); String logfile = args[2]; - HttpServer server = HttpServer.create(new InetSocketAddress(port), 200); + HttpServer server; + if(port==443) { + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new HttpsConfigurator (ctx)); + server = httpsServer; + } else { + server = HttpServer.create(new InetSocketAddress(port), 8192); + } HttpHandler h = new FileServerHandler(rootDir); HttpHandler h1 = new EchoHandler(); HttpHandler h2 = new DevNullHandler(); + HttpHandler h3 = new HelloWorldHandler(); HttpContext c = server.createContext("/files", h); c.getFilters().add(new LogFilter(new File(logfile))); @@ -74,8 +86,11 @@ public static void main(String[] args) throws Exception { HttpContext c2 = server.createContext("/devnull", h2); c2.getFilters().add(new LogFilter(new File(logfile))); + HttpContext c3 = server.createContext("/", h3); + + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); - // server.setExecutor(Executors.newCachedThreadPool()); + // server.setExecutor(Executors.newFixedThreadPool(8)); server.start(); } @@ -98,4 +113,15 @@ public void handle(HttpExchange exchange) throws IOException { } } + private static class HelloWorldHandler implements HttpHandler { + private static final byte[] bytes = "Hello World".getBytes(); + public void handle(HttpExchange exchange) throws IOException { + QueryParameters qp = QueryParameters.decode(ContentEncoding.encoding(exchange.getRequestHeaders()), exchange.getRequestURI().getQuery()); + long size = bytes.length; + exchange.sendResponseHeaders(200, size); + OutputStream os = exchange.getResponseBody(); + os.write(bytes); + os.close(); + } + } } diff --git a/src/test/java/EchoHandler.java b/src/test/java/EchoHandler.java index c3bbea7..2879e83 100644 --- a/src/test/java/EchoHandler.java +++ b/src/test/java/EchoHandler.java @@ -38,23 +38,30 @@ public void handle (HttpExchange t) throws IOException { InputStream is = t.getRequestBody(); + OutputStream os = t.getResponseBody(); + Headers map = t.getRequestHeaders(); - String fixedrequest = map.getFirst ("XFixed"); + String fixedrequest = map.getFirst("Xfixed"); // return the number of bytes received (no echo) - String summary = map.getFirst ("XSummary"); - OutputStream os = t.getResponseBody(); - byte[] in; - in = is.readAllBytes(); - if (summary != null) { - in = Integer.toString(in.length).getBytes(StandardCharsets.UTF_8); - } - if (fixedrequest != null) { - t.sendResponseHeaders(200, in.length == 0 ? -1 : in.length); - } else { + String summary = map.getFirst ("Xsummary"); + + if(fixedrequest==null && summary==null) { t.sendResponseHeaders(200, 0); + is.transferTo(os); + } else { + byte[] in; + in = is.readAllBytes(); + if (summary != null) { + in = Integer.toString(in.length).getBytes(StandardCharsets.UTF_8); + } + if (fixedrequest != null) { + t.sendResponseHeaders(200, in.length == 0 ? -1 : in.length); + } else { + t.sendResponseHeaders(200, 0); + } + os.write(in); } - os.write(in); close(t, os); close(t, is); } diff --git a/src/test/java/FileServerHandler.java b/src/test/java/FileServerHandler.java index bff7844..c52f541 100644 --- a/src/test/java/FileServerHandler.java +++ b/src/test/java/FileServerHandler.java @@ -26,8 +26,12 @@ import java.util.logging.*; import java.io.*; import java.net.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.*; + import javax.net.ssl.*; + import com.sun.net.httpserver.*; /** @@ -57,19 +61,23 @@ public void handle (HttpExchange t) URI uri = t.getRequestURI(); String path = uri.getPath(); - int x = 0; - while (is.read () != -1) x++; + is.readAllBytes(); is.close(); + File f = new File (docroot, path); + Path filepath = Files.isSymbolicLink(f.toPath()) ? Files.readSymbolicLink(f.toPath()) : f.toPath(); + f = filepath.toFile(); + if (!f.exists()) { notfound (t, path); return; } + String fixedrequest = map.getFirst ("XFixed"); String method = t.getRequestMethod(); if (method.equals ("HEAD")) { - rmap.set ("Content-Length", Long.toString (f.length())); + rmap.set ("Content-length", Long.toString (f.length())); t.sendResponseHeaders (200, -1); t.close(); } else if (!method.equals("GET")) { @@ -79,9 +87,9 @@ public void handle (HttpExchange t) } if (path.endsWith (".html") || path.endsWith (".htm")) { - rmap.set ("Content-Type", "text/html"); + rmap.set ("Content-type", "text/html"); } else { - rmap.set ("Content-Type", "text/plain"); + rmap.set ("Content-type", "text/plain"); } if (f.isDirectory()) { if (!path.endsWith ("/")) { @@ -109,21 +117,9 @@ public void handle (HttpExchange t) clen = 0; } t.sendResponseHeaders (200, clen); - OutputStream os = t.getResponseBody(); - FileInputStream fis = new FileInputStream (f); - int count = 0; - try { - byte[] buf = new byte [16 * 1024]; - int len; - while ((len=fis.read (buf)) != -1) { - os.write (buf, 0, len); - count += len; - } - } catch (IOException e) { - e.printStackTrace(); + try (OutputStream os = t.getResponseBody(); FileInputStream fis = new FileInputStream (f)) { + fis.transferTo(os); } - fis.close(); - os.close(); } } diff --git a/src/test/java/InputNotRead.java b/src/test/java/InputNotRead.java index 5b67f8d..5844471 100644 --- a/src/test/java/InputNotRead.java +++ b/src/test/java/InputNotRead.java @@ -35,11 +35,8 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -55,6 +52,8 @@ import static java.nio.charset.StandardCharsets.*; +import jdk.test.lib.RawClient; + public class InputNotRead { private static final int msgCode = 200; @@ -106,7 +105,7 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort(), -1); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(), someContext, "I will send all of the data", -1); } finally { System.out.println("shutting server down"); executor.shutdown(); @@ -148,7 +147,7 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort(), 64 * 1024 + 16); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(), someContext, "send some data to trigger the output", 64 * 1024 + 16); } finally { System.out.println("shutting server down"); executor.shutdown(); @@ -157,74 +156,5 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server finished."); } - static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) - throws Exception - { - Socket socket = null; - PrintWriter writer = null; - BufferedReader reader = null; - final String CRLF = "\r\n"; - try { - socket = new Socket(address, port); - writer = new PrintWriter(new OutputStreamWriter( - socket.getOutputStream())); - System.out.println("Client connected by socket: " + socket); - String body = "I will send all the data."; - if (contentLength <= 0) - contentLength = body.getBytes(UTF_8).length; - - writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); - writer.print("User-Agent: Java/" - + System.getProperty("java.version") - + CRLF); - writer.print("Host: " + address.getHostName() + CRLF); - writer.print("Accept: */*" + CRLF); - writer.print("Content-Length: " + contentLength + CRLF); - writer.print("Connection: keep-alive" + CRLF); - writer.print(CRLF); // Important, else the server will expect that - // there's more into the request. - writer.flush(); - System.out.println("Client wrote request to socket: " + socket); - writer.print(body); - writer.flush(); - - reader = new BufferedReader(new InputStreamReader( - socket.getInputStream())); - System.out.println("Client start reading from server:" ); - String line = reader.readLine(); - for (; line != null; line = reader.readLine()) { - if (line.isEmpty()) { - break; - } - System.out.println("\"" + line + "\""); - } - System.out.println("Client finished reading from server" ); - } finally { - // give time to the server to try & drain its input stream - Thread.sleep(500); - // closes the client outputstream while the server is draining - // it - if (writer != null) { - writer.close(); - } - // give time to the server to trigger its assertion - // error before closing the connection - Thread.sleep(500); - if (reader != null) - try { - reader.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - if (socket != null) { - try { - socket.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - } - } - System.out.println("Client finished." ); - } } diff --git a/src/test/java/InputRead100Test.java b/src/test/java/InputRead100Test.java new file mode 100644 index 0000000..34d142f --- /dev/null +++ b/src/test/java/InputRead100Test.java @@ -0,0 +1,151 @@ +/** + * @test id=default + * @bug 8349670 + * @summary Test 100 continue response handling + * @run junit/othervm InputRead100Test + */ +/** + * @test id=preferIPv6 + * @bug 8349670 + * @summary Test 100 continue response handling ipv6 + * @run junit/othervm -Djava.net.preferIPv6Addresses=true InputRead100Test + */ +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.sun.net.httpserver.HttpServer; + +import org.testng.annotations.Test; + +import static java.nio.charset.StandardCharsets.*; + +public class InputRead100Test { + private static final String someContext = "/context"; + + static { + Logger.getLogger("").setLevel(Level.ALL); + Logger.getLogger("").getHandlers()[0].setLevel(Level.ALL); + } + + @Test + public static void testContinue() throws Exception { + System.out.println("testContinue()"); + InetAddress loopback = InetAddress.getLoopbackAddress(); + HttpServer server = HttpServer.create(new InetSocketAddress(loopback, 0), 0); + try { + server.createContext( + someContext, + msg -> { + System.err.println("Handling request: " + msg.getRequestURI()); + byte[] reply = "Here is my reply!".getBytes(UTF_8); + try { + msg.getRequestBody().readAllBytes(); + msg.sendResponseHeaders(200, reply.length); + msg.getResponseBody().write(reply); + msg.getResponseBody().close(); + } finally { + System.err.println("Request handled: " + msg.getRequestURI()); + } + }); + server.start(); + System.out.println("Server started at port " + server.getAddress().getPort()); + + runRawSocketHttpClient(loopback, server.getAddress().getPort(), 0); + } finally { + System.out.println("shutting server down"); + server.stop(0); + } + System.out.println("Server finished."); + } + + static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) + throws Exception { + Socket socket = null; + PrintWriter writer = null; + BufferedReader reader = null; + + boolean foundContinue = false; + + final String CRLF = "\r\n"; + try { + socket = new Socket(address, port); + writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream())); + System.out.println("Client connected by socket: " + socket); + String body = "I will send all the data."; + if (contentLength <= 0) contentLength = body.getBytes(UTF_8).length; + + writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); + writer.print("User-Agent: Java/" + System.getProperty("java.version") + CRLF); + writer.print("Host: " + address.getHostName() + CRLF); + writer.print("Accept: */*" + CRLF); + writer.print("Content-Length: " + contentLength + CRLF); + writer.print("Connection: keep-alive" + CRLF); + writer.print("Expect: 100-continue" + CRLF); + writer.print(CRLF); // Important, else the server will expect that + // there's more into the request. + writer.flush(); + System.out.println("Client wrote request to socket: " + socket); + System.out.println("Client read 100 Continue response from server and headers"); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + String line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("interim response \"" + line + "\""); + if (line.startsWith("HTTP/1.1 100")) { + foundContinue = true; + } + } + if (!foundContinue) { + throw new IOException("Did not receive 100 continue from server"); + } + writer.print(body); + writer.flush(); + System.out.println("Client wrote body to socket: " + socket); + + System.out.println("Client start reading from server:"); + line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("final response \"" + line + "\""); + } + System.out.println("Client finished reading from server"); + } finally { + // give time to the server to try & drain its input stream + Thread.sleep(500); + // closes the client outputstream while the server is draining + // it + if (writer != null) { + writer.close(); + } + // give time to the server to trigger its assertion + // error before closing the connection + Thread.sleep(500); + if (reader != null) + try { + reader.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + if (socket != null) { + try { + socket.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + } + } + System.out.println("Client finished."); + } +} diff --git a/src/test/java/jdk/test/lib/RawClient.java b/src/test/java/jdk/test/lib/RawClient.java new file mode 100644 index 0000000..6254309 --- /dev/null +++ b/src/test/java/jdk/test/lib/RawClient.java @@ -0,0 +1,91 @@ +package jdk.test.lib; + +import java.io.*; +import java.net.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class RawClient { + + /** + * performs an HTTP request using a raw socket. + * + * @param address the server address + * @param port the server port + * @param context the server context (i.e. endpoint) + * @param data the data to send in the request body + * @param contentLength the content length, if > 0, this will be used as the + * Content-length header value which can be used to simulate the client + * advertising more data than it sends. in almost all cases this parameter + * should be 0, upon which the actual length of the data is used. + * @throws Exception + */ + public static void runRawSocketHttpClient(InetAddress address, int port, String context, String data, int contentLength) + throws Exception { + Socket socket = null; + PrintWriter writer = null; + BufferedReader reader = null; + final String CRLF = "\r\n"; + try { + socket = new Socket(address, port); + writer = new PrintWriter(new OutputStreamWriter( + socket.getOutputStream())); + System.out.println("Client connected by socket: " + socket); + String body = data == null ? "" : data; + if (contentLength <= 0) { + contentLength = body.getBytes(UTF_8).length; + } + + writer.print("GET " + context + "/ HTTP/1.1" + CRLF); + writer.print("User-Agent: Java/" + + System.getProperty("java.version") + + CRLF); + writer.print("Host: " + address.getHostName() + CRLF); + writer.print("Accept: */*" + CRLF); + writer.print("Content-Length: " + contentLength + CRLF); + writer.print("Connection: keep-alive" + CRLF); + writer.print(CRLF); // Important, else the server will expect that + // there's more into the request. + writer.flush(); + System.out.println("Client wrote request to socket: " + socket); + writer.print(body); + writer.flush(); + + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + System.out.println("Client start reading from server:"); + String line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("\"" + line + "\""); + } + System.out.println("Client finished reading from server"); + } finally { + // give time to the server to try & drain its input stream + Thread.sleep(500); + // closes the client outputstream while the server is draining + // it + if (writer != null) { + writer.close(); + } + // give time to the server to trigger its assertion + // error before closing the connection + Thread.sleep(500); + if (reader != null) + try { + reader.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + if (socket != null) { + try { + socket.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + } + } + System.out.println("Client finished."); + } + +} diff --git a/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java b/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java new file mode 100644 index 0000000..f649ffc --- /dev/null +++ b/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java @@ -0,0 +1,77 @@ +package robaho.net.httpserver; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; + +import static org.testng.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; + +public class HttpsConfiguratorTest { + volatile boolean configureCalled = false; + @Test + public void TestConfigureCalled() throws IOException, URISyntaxException { + int port = 8443; + + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new MyHttpsConfigurator (ctx)); + httpsServer.createContext("/test", (HttpExchange he) -> { + he.sendResponseHeaders(200,0); + try (var os = he.getResponseBody()) { + os.write("Hello".getBytes()); + } + }); + httpsServer.start(); + try { + URL url = URIBuilder.newBuilder() + .scheme("https") + .loopback() + .port(httpsServer.getAddress().getPort()) + .path("/test") + .toURL(); + HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection(Proxy.NO_PROXY); + urlc.setSSLSocketFactory (ctx.getSocketFactory()); + urlc.setHostnameVerifier (new DummyVerifier()); + urlc.getInputStream().readAllBytes(); + assertTrue(urlc.getResponseCode()==200); + } finally { + httpsServer.stop(0); + } + assertTrue(configureCalled); + } + + class MyHttpsConfigurator extends HttpsConfigurator { + public MyHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + super.configure(params); + configureCalled = true; + } + } + public class DummyVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession s1) { + return true; + } + } +} diff --git a/src/test/java/robaho/net/httpserver/LogFilter.java b/src/test/java/robaho/net/httpserver/LogFilter.java index 8c9729e..108bc6b 100644 --- a/src/test/java/robaho/net/httpserver/LogFilter.java +++ b/src/test/java/robaho/net/httpserver/LogFilter.java @@ -22,19 +22,20 @@ * questions. */ -import java.util.*; -import java.text.*; import java.io.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + import com.sun.net.httpserver.*; public class LogFilter extends Filter { PrintStream ps; - DateFormat df; + DateTimeFormatter df; public LogFilter(File file) throws IOException { - ps = new PrintStream(new FileOutputStream(file)); - df = DateFormat.getDateTimeInstance(); + ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file))); + df = DateTimeFormatter.ISO_DATE_TIME; } /** @@ -42,12 +43,17 @@ public LogFilter(File file) throws IOException { */ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { chain.doFilter(t); - HttpContext context = t.getHttpContext(); - Headers rmap = t.getRequestHeaders(); - String s = df.format(new Date()); - s = s + " " + t.getRequestMethod() + " " + t.getRequestURI() + " "; - s = s + " " + t.getResponseCode() + " " + t.getRemoteAddress(); - ps.println(s); + StringBuilder sb = new StringBuilder(); + df.formatTo(LocalDateTime.now(),sb); + sb.append(" "); + sb.append(t.getRequestMethod()); + sb.append(" "); + sb.append(t.getRequestURI()); + sb.append(" "); + sb.append(t.getResponseCode()); + sb.append(" "); + sb.append(t.getRemoteAddress()); + ps.println(sb.toString()); } public void init(HttpContext ctx) { diff --git a/src/test/java/robaho/net/httpserver/PipeliningStallTest.java b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java new file mode 100644 index 0000000..9ca2d3a --- /dev/null +++ b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java @@ -0,0 +1,99 @@ +package robaho.net.httpserver; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.testng.annotations.Test; + +import jdk.test.lib.RawClient; + +/** + * see issue #19 + * + * the server attempts to optimize flushing the response stream if there is + * another request in the pipeline, but the bug caused the server to assume the + * data remaining to be read was part of the next request, causing the server to + * hang. Reading even a single character from the request body would have + * prevented the issue since the buffer would have been filled. + * + * The solution is to read the remaining request data, then check if there are + * any characters waiting to be read. + */ +public class PipeliningStallTest { + + private static final int msgCode = 200; + private static final String someContext = "/context"; + + static class ServerThreadFactory implements ThreadFactory { + + static final AtomicLong tokens = new AtomicLong(); + + @Override + public Thread newThread(Runnable r) { + var thread = new Thread(r, "Server-" + tokens.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + } + + static { + Logger.getLogger("").setLevel(Level.ALL); + Logger.getLogger("").getHandlers()[0].setLevel(Level.ALL); + } + + @Test + public void testSendResponse() throws Exception { + System.out.println("testSendResponse()"); + InetAddress loopback = InetAddress.getLoopbackAddress(); + HttpServer server = HttpServer.create(new InetSocketAddress(loopback, 0), 0); + ExecutorService executor = Executors.newCachedThreadPool(new ServerThreadFactory()); + server.setExecutor(executor); + try { + server.createContext(someContext, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + var length = exchange.getRequestHeaders().getFirst("Content-Length"); + + var msg = "hi"; + var status = 200; + if (Integer.valueOf(length) > 4) { + msg = "oversized"; + status = 413; + } + + var bytes = msg.getBytes(); + + // -1 means no content, 0 means unknown content length + var contentLength = bytes.length == 0 ? -1 : bytes.length; + + try (OutputStream os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(status, contentLength); + os.write(bytes); + } + } + }); + server.start(); + System.out.println("Server started at port " + + server.getAddress().getPort()); + + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(),someContext,"I will send all of the data", -1); + } finally { + System.out.println("shutting server down"); + executor.shutdown(); + server.stop(0); + } + System.out.println("Server finished."); + } +} diff --git a/src/test/java/robaho/net/httpserver/RequestHeadersTest.java b/src/test/java/robaho/net/httpserver/RequestHeadersTest.java index 8efad4b..aa01d89 100644 --- a/src/test/java/robaho/net/httpserver/RequestHeadersTest.java +++ b/src/test/java/robaho/net/httpserver/RequestHeadersTest.java @@ -3,6 +3,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.List; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -63,4 +64,14 @@ public void TestWhitespace() throws IOException { assertEquals(r.headers().getFirst("KEY2"),"VAL2"); } + @Test + public void TestDuplicateHeaders() throws IOException { + String request = "GET blah\r\nKEY : VAL\r\nKEY:VAL2\r\nKEY:VAL3 \r\n\r\nSome Body Data"; + var is = new ByteArrayInputStream(request.getBytes()); + var os = new ByteArrayOutputStream(); + + Request r = new Request(is,os); + assertTrue("GET blah".contentEquals(r.requestLine())); + assertEquals(r.headers().get("KEY"), List.of("VAL", "VAL2", "VAL3")); + } } \ No newline at end of file diff --git a/src/test/java/robaho/net/httpserver/SSLSessionTest.java b/src/test/java/robaho/net/httpserver/SSLSessionTest.java new file mode 100644 index 0000000..6be28a0 --- /dev/null +++ b/src/test/java/robaho/net/httpserver/SSLSessionTest.java @@ -0,0 +1,79 @@ +package robaho.net.httpserver; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; + +import static org.testng.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsExchange; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; + +public class SSLSessionTest { + volatile boolean hasSslSession = false; + @Test + public void TestHasSslSession() throws IOException, URISyntaxException { + int port = 8443; + + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new MyHttpsConfigurator (ctx)); + httpsServer.createContext("/test", (HttpExchange he) -> { + var sslSession = ((HttpsExchange)he).getSSLSession(); + hasSslSession = sslSession!=null; + he.sendResponseHeaders(200,0); + try (var os = he.getResponseBody()) { + os.write("Hello".getBytes()); + } + }); + httpsServer.start(); + try { + URL url = URIBuilder.newBuilder() + .scheme("https") + .loopback() + .port(httpsServer.getAddress().getPort()) + .path("/test") + .toURL(); + HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection(Proxy.NO_PROXY); + urlc.setSSLSocketFactory (ctx.getSocketFactory()); + urlc.setHostnameVerifier (new DummyVerifier()); + urlc.getInputStream().readAllBytes(); + assertTrue(urlc.getResponseCode()==200); + } finally { + httpsServer.stop(0); + } + assertTrue(hasSslSession); + } + + class MyHttpsConfigurator extends HttpsConfigurator { + public MyHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + super.configure(params); + } + } + public class DummyVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession s1) { + return true; + } + } +} diff --git a/src/test/java/robaho/net/httpserver/StringUtilsTest.java b/src/test/java/robaho/net/httpserver/StringUtilsTest.java new file mode 100644 index 0000000..f32c1ce --- /dev/null +++ b/src/test/java/robaho/net/httpserver/StringUtilsTest.java @@ -0,0 +1,22 @@ +package robaho.net.httpserver; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; +import org.testng.annotations.Test; + +public class StringUtilsTest { + @Test + public void TestContainsIgnoreCase() throws IOException { + assertTrue(Utils.containsIgnoreCase("Keep-alive","keep-alive")); + assertTrue(Utils.containsIgnoreCase("Keep-alive, upgrade","Upgrade")); + assertTrue(Utils.containsIgnoreCase("upgrade, keep-alive","Upgrade")); + assertTrue(Utils.containsIgnoreCase("upgrade, keep-alive","upgrade")); + assertFalse(Utils.containsIgnoreCase("Keep-alive, upgrde","Upgrade")); + } + +} \ No newline at end of file diff --git a/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java b/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java index 0a399e8..12b065d 100644 --- a/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java +++ b/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java @@ -52,13 +52,16 @@ public void testFiles() throws UnsupportedEncodingException, IOException { s += "111Y\r\n"; s += "111Z\rCCCC\nCCCC\r\nCCCCC@\r\n"; + Assert.assertEquals(values.get(0).contentType(),"text/plain"); Assert.assertEquals(s.getBytes("UTF-8"), Files.readAllBytes((values.get(0).file()).toPath()), "file1 failed"); + s = "\r\n"; s += "@22X"; s += "222Y\r\n"; s += "222Z\r222W\n2220\r\n666@"; + Assert.assertEquals(values.get(1).contentType(),"text/plain"); Assert.assertEquals(s.getBytes("UTF-8"), Files.readAllBytes((values.get(1).file()).toPath()), "file2 failed"); } @@ -118,6 +121,7 @@ public void testFormSample() throws IOException { Assert.assertEquals(results.size(), 1); List values = results.get("myfile"); + Assert.assertEquals(values.get(0).contentType(), "text/plain"); Assert.assertEquals(values.size(), 1); } @@ -133,6 +137,12 @@ public void testMultiFileFormSample() throws IOException { Assert.assertEquals(results.size(), 2); List values = results.get("myfile"); + Assert.assertEquals(values.get(0).contentType(), "text/plain"); + Assert.assertEquals(values.size(), 1); + + values = results.get("myfile2"); + Assert.assertEquals(values.get(0).contentType(), "image/png"); Assert.assertEquals(values.size(), 1); + } } diff --git a/src/test/java/robaho/net/httpserver/http2/Http2Test.java b/src/test/java/robaho/net/httpserver/http2/Http2Test.java new file mode 100644 index 0000000..c593630 --- /dev/null +++ b/src/test/java/robaho/net/httpserver/http2/Http2Test.java @@ -0,0 +1,88 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.concurrent.Executors; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import robaho.net.httpserver.Http2ExchangeImpl; +import robaho.net.httpserver.LoggingFilter; + +@Test(enabled=false) // this is disabled since JDK HttpClient cannot perform "prior knowledge" Http2 connections over non-SSL +public class Http2Test { + + static { + // System.setProperty("jdk.httpclient.HttpClient.log", "all"); + // System.setProperty("jdk.internal.httpclient.websocket.debug", "true"); + } + + private static final int port = 9000; + private static final String path = "/echo"; + + HttpServer server; + + private volatile boolean foundHttp2 = false; + + @BeforeMethod + public void setUp() throws IOException { + Logger logger = Logger.getLogger(Http2Test.class.getName()); + ConsoleHandler ch = new ConsoleHandler(); + logger.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + logger.addHandler(ch); + + server = HttpServer.create(new InetSocketAddress(port), 0); + HttpHandler h = new EchoHandler(); + HttpContext c = server.createContext(path, h); + c.getFilters().add(new LoggingFilter(logger)); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + } + + @AfterMethod + public void tearDown() { + server.stop(0); + } + + @Test(enabled=false) + public void testHttp2Request() throws InterruptedException, IOException, URISyntaxException { + var client = HttpClient.newBuilder().version(Version.HTTP_2).build(); + var request = HttpRequest.newBuilder(new URI("http://localhost:9000"+path)).POST(HttpRequest.BodyPublishers.ofString("This is a test")).build(); + var response = client.send(request,BodyHandlers.ofString()); + Assert.assertEquals(response.body(),"This is a test"); + Assert.assertTrue(foundHttp2); + } + + private class EchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange he) throws IOException { + if(he instanceof Http2ExchangeImpl) { + foundHttp2 = true; + } + he.sendResponseHeaders(200,0); + try (var exchange = he) { + exchange.getRequestBody().transferTo(exchange.getResponseBody()); + } + } + + } +} diff --git a/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java b/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java index cd4ac33..b134572 100644 --- a/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java +++ b/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java @@ -155,6 +155,8 @@ private void testResponseHeader(String key, String expected) { public void testConnectionHeaderHandlesKeepAlive_FixingFirefoxConnectIssue() throws IOException { this.headers.set("connection", "keep-alive, Upgrade"); handler.handle(exchange); + testResponseHeader(Util.HEADER_WEBSOCKET_ACCEPT, "HSmrc0sMlYUkAGmm5OPpG2HaGWk="); + testResponseHeader(Util.HEADER_WEBSOCKET_PROTOCOL, "chat"); } @Test diff --git a/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java b/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java index 4a73bd4..10721e9 100644 --- a/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java +++ b/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java @@ -32,7 +32,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import jdk.test.lib.net.URIBuilder; -import robaho.net.httpserver.HttpExchangeAccess; /** * @test @@ -67,8 +66,10 @@ public void handle(HttpExchange t) throws IOException { ; t.sendResponseHeaders(200, -1); } - if (!HttpExchangeAccess.isClosed(t)) { + try { + t.getResponseBody().write("somedata".getBytes()); exchangeCloseFail.set(true); + } catch (IOException expected) { } latch.countDown(); } diff --git a/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java b/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java deleted file mode 100644 index 9495dc0..0000000 --- a/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ - -package robaho.net.httpserver; - -public class HttpExchangeAccess { - public static boolean isClosed(com.sun.net.httpserver.HttpExchange exch) { - synchronized (exch) { - return ((HttpExchangeImpl) exch).getExchangeImpl().closed; - } - } -} diff --git a/src/test/test_mains/B6361557.java b/src/test/test_mains/B6361557.java index 57e831e..39c96a7 100644 --- a/src/test/test_mains/B6361557.java +++ b/src/test/test_mains/B6361557.java @@ -65,7 +65,7 @@ public void handle (HttpExchange t) } final static String request = "GET /test/foo.html HTTP/1.1\r\nContent-length: 0\r\n\r\n"; - final static ByteBuffer requestBuf = ByteBuffer.allocate(64).put(request.getBytes()); + final static ByteBuffer requestBuf = ByteBuffer.wrap(request.getBytes()); public static void main (String[] args) throws Exception { Handler handler = new Handler(); diff --git a/src/test/test_mains/MissingTrailingSpace.java b/src/test/test_mains/MissingTrailingSpace.java index 6671105..6cc1a5b 100644 --- a/src/test/test_mains/MissingTrailingSpace.java +++ b/src/test/test_mains/MissingTrailingSpace.java @@ -32,18 +32,16 @@ import java.net.InetAddress; import java.net.InetSocketAddress; -import java.io.InputStreamReader; import java.io.IOException; -import java.io.BufferedReader; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import jdk.test.lib.RawClient; + public class MissingTrailingSpace { private static final int noMsgCode = 207; @@ -72,7 +70,7 @@ public void handle(HttpExchange msg) { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort()); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(),someContext,"", -1); } finally { ((ExecutorService)server.getExecutor()).shutdown(); server.stop(0); @@ -80,67 +78,4 @@ public void handle(HttpExchange msg) { System.out.println("Server finished."); } - static void runRawSocketHttpClient(InetAddress address, int port) - throws Exception - { - Socket socket = null; - PrintWriter writer = null; - BufferedReader reader = null; - final String CRLF = "\r\n"; - try { - socket = new Socket(address, port); - writer = new PrintWriter(new OutputStreamWriter( - socket.getOutputStream())); - System.out.println("Client connected by socket: " + socket); - - writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); - writer.print("User-Agent: Java/" - + System.getProperty("java.version") - + CRLF); - writer.print("Host: " + address.getHostName() + CRLF); - writer.print("Accept: */*" + CRLF); - writer.print("Connection: keep-alive" + CRLF); - writer.print(CRLF); // Important, else the server will expect that - // there's more into the request. - writer.flush(); - System.out.println("Client wrote rquest to socket: " + socket); - - reader = new BufferedReader(new InputStreamReader( - socket.getInputStream())); - System.out.println("Client start reading from server:" ); - String line = reader.readLine(); - if ( !line.endsWith(" ") ) { - throw new RuntimeException("respond to unknown code " - + noMsgCode - + " doesn't return space at the end of the first header.\n" - + "Should be: " + "\"" + line + " \"" - + ", but returns: " + "\"" + line + "\"."); - } - for (; line != null; line = reader.readLine()) { - if (line.isEmpty()) { - break; - } - System.out.println("\"" + line + "\""); - } - System.out.println("Client finished reading from server" ); - } finally { - if (reader != null) - try { - reader.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - if (writer != null) { - writer.close(); - } - if (socket != null) { - try { - socket.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - } - } - System.out.println("Client finished." ); - } }