├── .clj-kondo └── config.edn ├── .dockerignore ├── dev.env ├── resources └── simplelogger.properties ├── Dockerfile ├── docker-push.sh ├── .gitignore ├── deps.edn ├── test └── ivarref │ └── http_test.clj ├── LICENSE ├── README.md └── src └── com └── github └── ivarref └── mikkmokk_proxy.clj /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {dom-top.core/letr clojure.core/let}} 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !src/ 3 | !.git/ 4 | !resources/ 5 | !deps.edn 6 | !build.edn 7 | -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | MIKKMOKK_DEVELOPMENT=true 2 | #DESTINATION_URL=http://example.com 3 | 4 | ADMIN_BIND=0.0.0.0 5 | ADMIN_PORT=7070 6 | PROXY_BIND=0.0.0.0 7 | PROXY_PORT=8080 8 | 9 | TZ=Europe/Oslo 10 | -------------------------------------------------------------------------------- /resources/simplelogger.properties: -------------------------------------------------------------------------------- 1 | org.slf4j.simpleLogger.defaultLogLevel=info 2 | #org.slf4j.simpleLogger.log.xxxxx= 3 | org.slf4j.simpleLogger.showDateTime=true 4 | #org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss 5 | org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z 6 | org.slf4j.simpleLogger.showThreadName=false 7 | org.slf4j.simpleLogger.levelInBrackets=true 8 | org.slf4j.simpleLogger.showLogName=false 9 | org.slf4j.simpleLogger.showShortLogName=false 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clojure:temurin-17-tools-deps-1.11.1.1113-focal as builder 2 | WORKDIR /src 3 | COPY deps.edn . 4 | COPY build.edn . 5 | RUN clojure -P && clojure -P -T:build 6 | COPY src/ src/ 7 | COPY .git/ .git/ 8 | COPY resources/ resources/ 9 | RUN clojure -T:build uberjar 10 | 11 | FROM eclipse-temurin:22.0.1_8-jdk-jammy 12 | COPY --from=builder /src/target/mikkmokk-proxy-standalone.jar /mikkmokk-proxy-standalone.jar 13 | ENTRYPOINT ["java", "-jar", "/mikkmokk-proxy-standalone.jar"] 14 | -------------------------------------------------------------------------------- /docker-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | git update-index --refresh 6 | git diff-index --quiet HEAD -- 7 | 8 | VERSION="v0.1.$(git rev-list --count HEAD)" 9 | 10 | echo "Releasing $VERSION" 11 | 12 | docker buildx create --name multiarch --driver docker-container --use || true 13 | docker buildx build --push --platform linux/arm64,linux/amd64 --tag docker.io/ivarref/mikkmokk-proxy:"$VERSION" . 14 | 15 | git tag -a "$VERSION" -m "Release $VERSION" 16 | git push --follow-tags 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .calva/output-window/ 2 | .classpath 3 | .clj-kondo/.cache 4 | .clj-kondo/clj-commons 5 | .cpcache 6 | .DS_Store 7 | .eastwood 8 | .factorypath 9 | .hg/ 10 | .hgignore 11 | .java-version 12 | .lein-* 13 | .lsp/.cache 14 | .lsp/sqlite.db 15 | .nrepl-history 16 | .nrepl-port 17 | .portal 18 | .project 19 | .rebel_readline_history 20 | .settings 21 | .socket-repl-port 22 | .sw* 23 | .vscode 24 | *.class 25 | *.jar 26 | *.swp 27 | *~ 28 | /checkouts 29 | /classes 30 | /target 31 | .idea/ 32 | *.iml 33 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.11.1"} 3 | aleph/aleph {:mvn/version "0.6.1"} 4 | org.slf4j/slf4j-simple {:mvn/version "1.7.36"} 5 | lambdaisland/regal {:mvn/version "0.0.143"} 6 | dom-top/dom-top {:mvn/version "1.0.8"} 7 | clojure-term-colors/clojure-term-colors {:mvn/version "0.1.0"}} 8 | :aliases {:build {:deps {com.github.liquidz/build.edn {:git/tag "0.3.90" :git/sha "e3a3e31"}} 9 | :ns-default build-edn.main} 10 | :test {:extra-paths ["test"] 11 | :extra-deps {io.github.cognitect-labs/test-runner 12 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 13 | :main-opts ["-m" "cognitect.test-runner"] 14 | :exec-fn cognitect.test-runner.api/test}}} 15 | -------------------------------------------------------------------------------- /test/ivarref/http_test.clj: -------------------------------------------------------------------------------- 1 | (ns ivarref.http-test 2 | (:require [aleph.http :as http] 3 | [aleph.netty :as netty] 4 | [clj-commons.byte-streams :as bs] 5 | [clojure.test :refer [deftest is use-fixtures]] 6 | [clojure.tools.logging :as log] 7 | [com.github.ivarref.mikkmokk-proxy :as mm]) 8 | (:import (java.util.concurrent Executors))) 9 | 10 | (def ^:dynamic *server-port* nil) 11 | (def ^:dynamic *admin-port* nil) 12 | 13 | (defn with-server [f] 14 | (let [state {:one-off (atom #{}) 15 | :env {}} 16 | server (http/start-server (fn [req] (mm/outer-handler state req)) {:executor (Executors/newFixedThreadPool 8) :port 0}) 17 | admin (http/start-server (fn [req] (mm/admin-handler state req)) {:executor (Executors/newFixedThreadPool 2) :port 0})] 18 | (try 19 | (with-redefs [mm/single-request (fn [_request-method url headers _body] 20 | {:status 200 21 | :body url 22 | :headers (dissoc headers "content-length")})] 23 | (binding [*server-port* (netty/port server) 24 | *admin-port* (netty/port admin)] 25 | (f))) 26 | (finally 27 | (try 28 | (.close server) 29 | (catch Throwable t 30 | (log/error t "Error while trying to close server:" (ex-message t)))) 31 | (try 32 | (.close admin) 33 | (catch Throwable t 34 | (log/error t "Error while trying to close admin server:" (ex-message t)))))))) 35 | 36 | (use-fixtures :each with-server) 37 | 38 | (defn get-uri [uri headers] 39 | (try 40 | (-> @(http/get (str "http://localhost:" *server-port* uri) {:headers headers}) 41 | (update :body bs/to-string)) 42 | (catch Throwable t 43 | (-> (ex-data t) 44 | (update :body bs/to-string))))) 45 | 46 | (defn post-admin-uri [uri headers] 47 | (try 48 | (-> @(http/post (str "http://localhost:" *admin-port* uri) {:headers headers}) 49 | (update :body bs/to-string)) 50 | (catch Throwable t 51 | (ex-data t)))) 52 | 53 | (deftest basic 54 | (is (= 500 (:status (get-uri "/" {})))) 55 | (is (= "http://example.com/" (:body (get-uri "/" {"x-mikkmokk-destination-url" "http://example.com"})))) 56 | (is (= 200 (:status (get-uri "/" {"x-mikkmokk-destination-url" "http://example.com"})))) 57 | (is (= "http://example.com/" (:body (get-uri "/mikkmokk-fwd-http/example.com" {})))) 58 | (is (= "http://example.com/" (:body (get-uri "/mikkmokk-fwd-http/example.com/" {})))) 59 | (is (= "http://example.com/" (:body (get-uri "/mikkmokk-forward-http/example.com/" {}))))) 60 | 61 | (deftest match-uri-starts-with 62 | (is (= 200 (:status (get-uri "/no-match" {"x-mikkmokk-destination-url" "http://example.com" 63 | "x-mikkmokk-match-uri-starts-with" "/match" 64 | "x-mikkmokk-fail-before-percentage" "100"})))) 65 | (is (= 503 (:status (get-uri "/match" {"x-mikkmokk-destination-url" "http://example.com" 66 | "x-mikkmokk-match-uri-starts-with" "/match" 67 | "x-mikkmokk-fail-before-percentage" "100"})))) 68 | (is (= 503 (:status (get-uri "/match/more" {"x-mikkmokk-destination-url" "http://example.com" 69 | "x-mikkmokk-match-uri-starts-with" "/match" 70 | "x-mikkmokk-fail-before-percentage" "100"}))))) 71 | 72 | (deftest match-header 73 | (is (= 200 (:status (get-uri "/" {"x-mikkmokk-destination-url" "http://example.com" 74 | "x-mikkmokk-match-header-name" "x-user-id" 75 | "x-mikkmokk-match-header-value" "some-user-id" 76 | "x-mikkmokk-fail-before-percentage" "100"})))) 77 | (is (= 503 (:status (get-uri "/" {"x-mikkmokk-destination-url" "http://example.com" 78 | "x-mikkmokk-match-header-name" "x-user-id" 79 | "x-mikkmokk-match-header-value" "some-user-id" 80 | "x-user-id" "some-user-id" 81 | "x-mikkmokk-fail-before-percentage" "100"}))))) 82 | 83 | 84 | (deftest match-host 85 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/example.com/" 86 | {"x-mikkmokk-match-host" "peggy.gmbh.com" 87 | "x-mikkmokk-fail-before-percentage" "100"})))) 88 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/" 89 | {"x-mikkmokk-match-host" "example.com" 90 | "x-mikkmokk-fail-before-percentage" "100"})))) 91 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/peggy.gmbh.com/some-endpoint" 92 | {"x-mikkmokk-match-host" "peggy.gmbh.com" 93 | "x-mikkmokk-match-uri" "/some-endpoint2" 94 | "x-mikkmokk-fail-before-percentage" "100"})))) 95 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/peggy.gmbh.com/some-endpoint" 96 | {"x-mikkmokk-match-host" "peggy.gmbh.com" 97 | "x-mikkmokk-match-uri" "/some-endpoint" 98 | "x-mikkmokk-fail-before-percentage" "100"}))))) 99 | 100 | 101 | 102 | (deftest one-off 103 | (is (= "http://example.com/" (:body (get-uri "/mikkmokk-forward-http/example.com/" {})))) 104 | (is (= 200 (:status (post-admin-uri "/api/v1/one-off" {"x-mikkmokk-fail-before-percentage" "100"})))) 105 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/" {})))) 106 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/example.com/" {})))) 107 | 108 | (is (= 200 (:status (post-admin-uri "/api/v1/one-off" {"x-mikkmokk-fail-before-percentage" "100"})))) 109 | (is (= 200 (:status (post-admin-uri "/api/v1/one-off" {"x-mikkmokk-fail-before-percentage" "100"})))) 110 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/" {})))) 111 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/" {})))) 112 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/example.com/" {})))) 113 | 114 | (is (= 200 (:status (post-admin-uri "/api/v1/one-off" {"x-mikkmokk-match-host" "example.com" 115 | "x-mikkmokk-match-uri" "/some-endpoint" 116 | "x-mikkmokk-fail-before-percentage" "100"})))) 117 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/example.com/some-endpoint2" {})))) 118 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/peggy.gmbh.com/some-endpoint" {})))) 119 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/some-endpoint" {})))) 120 | #_(is (= 503 (:status (get-uri "/" {"x-mikkmokk-destination-url" "http://example.com" 121 | "x-mikkmokk-match-header-name" "x-user-id" 122 | "x-mikkmokk-match-header-value" "some-user-id" 123 | "x-user-id" "some-user-id" 124 | "x-mikkmokk-fail-before-percentage" "100"}))))) 125 | 126 | (defn origin [resp] 127 | (get-in resp [:headers "origin"])) 128 | 129 | (deftest modified-headers 130 | (is (= {"host" "example.com"} 131 | (-> (get-uri "/mikkmokk-forward-http/example.com/" {}) 132 | :headers 133 | (select-keys ["host" "origin"])))) 134 | (is (= {"host" "example.com" 135 | "origin" "http://example.com"} 136 | (-> (get-uri "/mikkmokk-forward-http/example.com/" {"origin" "http://localhost:8090"}) 137 | :headers 138 | (select-keys ["host" "origin"])))) 139 | (is (= "http://example.com:8080" 140 | (origin (get-uri "/mikkmokk-forward-http/example.com:8080/" {"origin" "http://localhost:8090"})))) 141 | (is (= "https://example.com:8080" 142 | (origin (get-uri "/mikkmokk-forward-https/example.com:8080/" {"origin" "http://localhost:8090"})))) 143 | (is (= "https://example.com:8080" 144 | (origin (get-uri "/mikkmokk-forward-https/example.com:8080/api" {"origin" "http://localhost:8090"}))))) 145 | 146 | (deftest test-Access-Control-Allow-Origin 147 | (is (= "http://localhost:8090" 148 | (-> (get-in (get-uri "/mikkmokk-forward-http/example.com:8080/" {"origin" "http://localhost:8090" 149 | "Access-Control-Allow-Origin" "demo"}) 150 | [:headers "Access-Control-ALLOW-Origin"]))))) 151 | 152 | 153 | (deftest matches-uri-regex 154 | (is (= 200 (:status (get-uri "/mikkmokk-forward-http/example.com/a123123123" 155 | {"x-mikkmokk-match-uri-regex" "/[0-9]+" 156 | "x-mikkmokk-fail-before-percentage" "100"})))) 157 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/123123123" 158 | {"x-mikkmokk-match-uri-regex" "/[0-9]+" 159 | "x-mikkmokk-fail-before-percentage" "100"})))) 160 | (is (= 503 (:status (get-uri "/mikkmokk-forward-http/example.com/api/uuid/af9facf3-f679-4245-aa83-1b95cea52a1d" 161 | {"x-mikkmokk-match-uri-regex" "/api/uuid/([a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12})" 162 | "x-mikkmokk-fail-before-percentage" "100"}))))) 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mikkmokk-proxy 2 | 3 | mikkmokk-proxy is an unobtrusive reverse proxy server that injects faults on the HTTP layer. 4 | 5 | Have you ever wanted to test how well your (backend|frontend) services handles 6 | * failed requests 7 | * duplicate requests 8 | * delayed requests 9 | 10 | mikkmokk-proxy is — literally — a gateway drug for resiliency testing on the HTTP layer. 11 | 12 | ## Overview 13 | 14 | ```mermaid 15 | sequenceDiagram 16 | client->>mikkmokk: POST 17 | mikkmokk->>mikkmokk: 💤delay before?💤 18 | mikkmokk->>client: 💥fail before?💥 19 | mikkmokk->>destination: POST 20 | mikkmokk->>destination: 💥duplicate POST?💥 21 | destination->>database: read and/or state change 22 | database->>destination: result 23 | destination->>mikkmokk: result 24 | mikkmokk->>mikkmokk: 💤delay after?💤 25 | mikkmokk->>client: result OR 💥fail after?💥 26 | ``` 27 | 28 | `client` here is anything that will normally access `destination` 29 | using the HTTP protocol, but goes via `mikkmokk` instead. 30 | 31 | mikkmokk can inject five different types of faults: 32 | * fail a request before the destination is reached 33 | * fail a request after the destination is reached 34 | * add a delay before accessing the destination 35 | * add a delay after accessing the destination 36 | * add a duplicate a request 37 | 38 | mikkmokk does fault injection based on a percentage chance. 39 | The scope for fault injection may be narrowed further by settings 40 | various matching criteria (URI, request method, header name/value pair, etc). 41 | 42 | The percentage chances, and related settings, can be 43 | set both statically and dynamically: 44 | * Using proxy headers `x-mikkmokk-...` when accessing the reverse proxy. 45 | * At runtime using the admin API, both for setting new defaults and for introducing one-off errors. 46 | * At startup time using environment variables. 47 | 48 | mikkmokk supports proxying to arbitrary URLs. 49 | 50 | ## Example usage 51 | 52 | #### Start the reverse proxy 53 | 54 | The following setup proxies to `http://example.com`: 55 | ```bash 56 | $ docker run --rm --name mikkmokk-proxy \ 57 | -e DESTINATION_URL=http://example.com \ 58 | -e PROXY_BIND=0.0.0.0 \ 59 | -e PROXY_PORT=8080 \ 60 | -e ADMIN_BIND=0.0.0.0 \ 61 | -e ADMIN_PORT=7070 \ 62 | -p 8080:8080 \ 63 | -p 7070:7070 \ 64 | docker.io/ivarref/mikkmokk-proxy:v0.1.63 65 | ``` 66 | 67 | There are two ports being exposed: 68 | * The reverse proxy on port 8080. 69 | * The admin server on port 7070. 70 | 71 | #### Issue a regular request 72 | 73 | ``` 74 | $ curl http://localhost:8080 75 | ... 76 |

Example Domain

77 |

This domain is for use in illustrative examples in documents. You may use this 78 | domain in literature without prior coordination or asking for permission.

79 |

More information...

80 | ... 81 | ``` 82 | This request succeeded because mikkmokk was not told to do any fault injection. 83 | 84 | #### Insert a failure before a request reaches the destination 85 | 86 | The header `x-mikkmokk-fail-before-percentage` can be used to simulate that 87 | the destination could not be reached. If present, it must be an int in the range 88 | \[0, 100\], i.e. it's the percentage chance that a request fails. 89 | The default value for this header is `0`. 90 | The value `100` means that the request will always fail. 91 | This can be used to test if clients are retrying or not. 92 | 93 | ``` 94 | $ curl -v -H 'x-mikkmokk-fail-before-percentage: 100' http://localhost:8080 95 | ... 96 | < HTTP/1.1 503 Service Unavailable 97 | < Content-Type: application/json 98 | ... 99 | {"error":"fail-before"} 100 | ``` 101 | 102 | The default HTTP status code for this is `503`, and may be changed 103 | using the header `x-mikkmokk-fail-before-code`. 104 | 105 | #### Insert a failure after a request has been processed by the destination 106 | 107 | The header `x-mikkmokk-fail-after-percentage` can be used to simulate that the 108 | destination has received and processed the request, but 109 | that the network between the proxy and the destination failed before 110 | the proxy received the response. Thus, the client will receive an incorrect response. 111 | If the client retries, will the backend handle a duplicate request? 112 | 113 | ``` 114 | $ curl -v -H 'x-mikkmokk-fail-after-percentage: 100' http://localhost:8080 115 | ... 116 | < HTTP/1.1 502 Bad Gateway 117 | < Content-Type: application/json 118 | ... 119 | {"error":"fail-after","destination-response-code":200} 120 | ``` 121 | 122 | The field `destination-response-code` states which HTTP status code the destination 123 | actually responded with. 124 | The default HTTP status code for this is `502`, and may be changed using the header `x-mikkmokk-fail-after-code`. 125 | 126 | #### Insert a duplicate request 127 | 128 | The header `x-mikkmokk-duplicate-percentage` instructs mikkmokk to make two identical, parallel requests. 129 | 130 | ``` 131 | $ curl -H 'x-mikkmokk-duplicate-percentage: 100' http://localhost:8080 132 | 133 | # In the mikkmokk logs you will see something like: 134 | > Duplicate request returned identical HTTP status code 200 for GET http://example.com/ 135 | ``` 136 | 137 | #### Only match a specific URI and/or request method 138 | 139 | ``` 140 | $ curl -H 'x-mikkmokk-match-uri: /something' \ 141 | -H 'x-mikkmokk-match-method: GET' \ 142 | -H 'x-mikkmokk-fail-before-percentage: 100' \ 143 | http://localhost:8080/something 144 | {"error":"fail-before"} 145 | ``` 146 | 147 | The default value of the `x-mikkmokk-match-uri` and `x-mikkmokk-match-method` headers is `*`, meaning that all URIs and all request methods will match. 148 | 149 | If you only want to match a given URI prefix, you may use the `x-mikkmokk-match-uri-starts-with` header. 150 | 151 | #### Only match a given header name/value pair 152 | 153 | ``` 154 | $ curl -H 'x-mikkmokk-match-header-name: x-some-header' \ 155 | -H 'x-mikkmokk-match-header-value: foobar' \ 156 | -H 'x-mikkmokk-fail-before-percentage: 100' \ 157 | http://localhost:8080/ 158 | ... request succeeds, header-name and -value did not match. 159 | 160 | $ curl -H 'x-mikkmokk-match-header-name: x-some-header' \ 161 | -H 'x-mikkmokk-match-header-value: foobar' \ 162 | -H 'x-mikkmokk-fail-before-percentage: 100' \ 163 | -H 'x-some-header: foobar' \ 164 | http://localhost:8080/ 165 | {"error":"fail-before"} 166 | ``` 167 | 168 | Here we see that the first request did not fail, and thus `x-mikkmokk-match-header-name` and 169 | `x-mikkmokk-header-value` did not match. 170 | 171 | On the second request it does fail however, and 172 | thus the header name-value pair did match. 173 | We explicitly set `x-some-header` ourselves. 174 | In a more real world setting it would be set by some gateway. 175 | 176 | #### Inserting delays 177 | 178 | Delays may be inserted using `x-mikkmokk-delay-before-percentage` and 179 | `x-mikkmokk-delay-before-ms`: 180 | 181 | ``` 182 | $ time curl -H 'x-mikkmokk-delay-before-percentage: 100' \ 183 | -H 'x-mikkmokk-delay-before-ms: 3000' \ 184 | http://localhost:8080 185 | ... 186 | real 0m3.252s 187 | ``` 188 | 189 | This delay will be inserted before the destination service is accessed. 190 | 191 | It's also possible to inject delays after the destination service has 192 | been accessed using `x-mikkmokk-delay-after-percentage` and 193 | `x-mikkmokk-delay-after-ms`. 194 | 195 | ### Use the admin API to introduce one-off errors 196 | 197 | Let's say that you want to test how a frontend handles a failed request, 198 | but you do not want edit the source code of the frontend. You also 199 | do not want to create any unnecessary errors. 200 | 201 | You can use the admin API for one-off errors for these tasks: 202 | 203 | ``` 204 | # Notice the port 7070 here, which is where we exposed the admin 205 | # API earlier: 206 | $ curl -XPOST -H 'x-mikkmokk-fail-before-percentage: 100' \ 207 | http://localhost:7070/api/v1/one-off 208 | {"service":"mikkmokk","message":"Added one-off"} 209 | 210 | # The next request now fails: 211 | $ curl http://localhost:8080 212 | {"error":"fail-before"} 213 | 214 | # The request after succeeds: 215 | $ curl http://localhost:8080 216 | ...

Example Domain

... 217 | ``` 218 | 219 | The one-off API also supports matching on URI, request method, 220 | headers, etc. 221 | 222 | 223 | ### Use the admin API to change defaults at runtime 224 | 225 | The admin API, running on port 7070 in this example, can be 226 | used to change the default headers for the runtime of the proxy. 227 | 228 | ``` 229 | $ curl -XPOST -H 'x-mikkmokk-fail-before-percentage: 20' http://localhost:7070/api/v1/update 230 | {"delay-after-ms":0, 231 | "delay-after-percentage":0, 232 | "delay-before-ms":0, 233 | "delay-before-percentage":0, 234 | "destination-url":"http://example.com", 235 | "duplicate-percentage":0, 236 | "fail-after-code":502, 237 | "fail-after-percentage":0, 238 | "fail-before-code":503, 239 | "fail-before-percentage":20, # <-- fail-before-percentage now has a new default value 240 | "match-header-name":"*", 241 | "match-header-value":"*", 242 | "match-host":"*", 243 | "match-method":"*", 244 | "match-uri":"*", 245 | "match-uri-starts-with":"*"} 246 | 247 | # Using the hey load generator https://github.com/rakyll/hey, 248 | # we can test if 20% of requests fail: 249 | $ hey -n 100 http://localhost:8080 250 | ... 251 | Status code distribution: 252 | [200] 78 responses 253 | [503] 22 responses 254 | 255 | # List current settings 256 | $ curl http://localhost:7070/api/v1/list 257 | {"delay-after-ms":0, 258 | "delay-after-percentage":0, 259 | "delay-before-ms":0, 260 | "delay-before-percentage":0, 261 | "destination-url":"http://example.com", 262 | "duplicate-percentage":0, 263 | "fail-after-code":502, 264 | "fail-after-percentage":0, 265 | "fail-before-code":503, 266 | "fail-before-percentage":20, 267 | "match-header-name":"*", 268 | "match-header-value":"*", 269 | "match-host":"*", 270 | "match-method":"*", 271 | "match-uri":"*", 272 | "match-uri-starts-with":"*"} 273 | 274 | # Reset the admin settings 275 | $ curl -XPOST http://localhost:7070/api/v1/reset 276 | {"delay-after-ms":0, 277 | "delay-after-percentage":0, 278 | "delay-before-ms":0, 279 | "delay-before-percentage":0, 280 | "destination-url":"http://example.com", 281 | "duplicate-percentage":0, 282 | "fail-after-code":502, 283 | "fail-after-percentage":0, 284 | "fail-before-code":503, 285 | "fail-before-percentage":0, # <-- fail-before-percentage now has the environment default 286 | "match-header-name":"*", 287 | "match-header-value":"*", 288 | "match-host":"*", 289 | "match-method":"*", 290 | "match-uri":"*", 291 | "match-uri-starts-with":"*"} 292 | 293 | $ hey -n 100 http://localhost:8080 294 | ... 295 | Status code distribution: 296 | [200] 100 responses 297 | ``` 298 | 299 | ### Proxying to arbitrary URLs 300 | 301 | mikkmokk supports a flexible URL proxying scheme. 302 | You do not need to create a single mikkmokk instance for every service you want to 303 | proxy to. 304 | Instead you can tell mikkmokk where to forward to using the URI: 305 | 306 | ``` 307 | $ curl http://localhost:8080/mikkmokk-forward-http/example.org 308 | ...

Example Domain

309 | 310 | # https scheme is also supported 311 | $ curl http://localhost:8080/mikkmokk-forward-https/example.org/some-other-endpoint 312 | ...

Example Domain

313 | ``` 314 | 315 | ### Headers modified by mikkmokk 316 | 317 | mikkmokk will automatically edit the `host` HTTP header when accessing the destination. 318 | It will also update the `origin` and `Access-Control-Allow-Origin` HTTP headers if present. 319 | 320 | ### All settings and default values 321 | 322 | | Header name | Description | Default value | 323 | |-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| 324 | | delay-after-ms | Number of milliseconds to delay after the destination has replied | 0 | 325 | | delay-after-percentage | Percentage chance of introducing delay after the destination has replied | 0 | 326 | | delay-before-ms | Number of milliseconds to delay before accessing the destination | 0 | 327 | | delay-before-percentage | Percentage chance of introducing delay before accessing the destination | 0 | 328 | | destination-url | Where to forward the request to. E.g. http://example.com | nil | 329 | | duplicate-percentage | Percentage chance of introducing a duplicate request | 0 | 330 | | fail-after-code | The HTTP status code to reply with if a request was deliberately aborted after accessing the destination | 502 | 331 | | fail-after-percentage | Percentage chance of aborting the request after accessing the destination | 0 | 332 | | fail-before-code | The HTTP status code to reply with if a request was deliberately aborted before accessing the destination | 503 | 333 | | fail-before-percentage | Percentage chance of aborting the request before accessing the destination | 0 | 334 | | match-header-name | Only apply failures and/or delays if this HTTP header name's value is identical to ... | * | 335 | | match-header-value | the value in this header. I.e. use this pair of headers to match an arbitrary header value. | * | 336 | | match-host | Only apply failures and/or delays if the destination host matches this value, e.g. `example.org` | * | 337 | | match-method | Only apply failures and/or delays to this HTTP method (GET, POST, HEAD, etc.) | * | 338 | | match-uri | Only apply failures and/or delays to this HTTP uri (e.g. `/my-api/my-endpoint`) | * | 339 | | match-uri-regex | Only apply failures and/or delays to this HTTP uri if it matches the entire regex. Tip: an uuid may be matched with the following: `([a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12})` | * | 340 | | match-uri-starts-with | Only apply failures and/or delays if the HTTP uri starts with this prefix | * | 341 | 342 | When using these settings as headers, you will need to prefix them with `x-mikkmokk-`. 343 | 344 | For environment variables, you will need to upper case them and replace dash with underscore, e.g. 345 | `destination-url` should become `DESTINATION_URL`. 346 | 347 | ## NAQ 348 | 349 | > Should I run mikkmokk-proxy on a public, untrusted network? 350 | 351 | No. 352 | 353 | > Should I run mikkmokk-proxy in production? 354 | 355 | No. 356 | 357 | > Can the logger show the time in my timezone? 358 | 359 | Yes. Set the environment property `TZ` to your timezone, e.g. `Europe/Oslo`. 360 | 361 | > NAQ? 362 | 363 | Yes, that's Never Asked Questions. ¯\\\_(ツ)\_/¯ 364 | 365 | ## Limitations 366 | No TLS/SSL support for the proxy server (bind). 367 | No WebSocket support. No SSE. 368 | 369 | There is no attempt at validating `-percentage` nor `-code` properties. 370 | `-percentage` should be [0, 100], and `-code` should be [200, 600). 371 | 372 | ## Alternatives and related software 373 | 374 | [envoyproxy](https://www.envoyproxy.io/) has a [fault injection filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/fault_filter#config-http-filters-fault-injection) that seems equivalent to `x-mikkmokk-fail-before-` headers. 375 | 376 | [mefellows/muxy](https://github.com/mefellows/muxy): Chaos engineering tool for simulating real-world distributed system failures. 377 | 378 | [bouncestorage/chaos-http-proxy](https://github.com/bouncestorage/chaos-http-proxy): Introduce failures into HTTP requests via a proxy server. 379 | 380 | [clusterfk/chaos-proxy](https://github.com/clusterfk/chaos-proxy): ClusterFk Chaos Proxy is an unreliable HTTP proxy you can rely on. 381 | 382 | [toxiproxy](https://github.com/Shopify/toxiproxy): A chaotic TCP proxy. 383 | 384 | ## Changelog 385 | 386 | #### 2024-05-06 v0.1.63 387 | * [Publish multiarch Docker image #1](https://github.com/ivarref/mikkmokk-proxy/issues/1). 388 | * Bump JDK version. 389 | 390 | #### 2023-05-12 v0.1.59 391 | If remote server sends header `Access-Control-Allow-Origin` in its response, 392 | set it to the input value of `Origin`. 393 | 394 | #### 2022-06-23 v0.1.54 395 | Remove logger name. 396 | 397 | #### 2022-06-23 v0.1.53 398 | Remove thread name from logging, add information about timezone when logging. 399 | 400 | #### 2022-06-23 v0.1.52 401 | Add coloring of HTTP status codes. 402 | 403 | #### 2022-06-19 v0.1.50 404 | Add `x-mikkmokk-match-uri-regex` header. 405 | 406 | #### 2022-06-16 v0.1.42 407 | First publicly announced release. 408 | 409 | ## License 410 | 411 | Copyright © 2022 Ivar Refsdal 412 | 413 | This program and the accompanying materials are made available under the 414 | terms of the Eclipse Public License 2.0 which is available at 415 | http://www.eclipse.org/legal/epl-2.0. 416 | 417 | This Source Code may also be made available under the following Secondary 418 | Licenses when the conditions for such availability set forth in the Eclipse 419 | Public License, v. 2.0 are satisfied: GNU General Public License as published by 420 | the Free Software Foundation, either version 2 of the License, or (at your 421 | option) any later version, with the GNU Classpath Exception which is available 422 | at https://www.gnu.org/software/classpath/license.html. 423 | -------------------------------------------------------------------------------- /src/com/github/ivarref/mikkmokk_proxy.clj: -------------------------------------------------------------------------------- 1 | (ns com.github.ivarref.mikkmokk-proxy 2 | (:require [aleph.http :as http] 3 | [clojure.set :as set] 4 | [clojure.string :as str] 5 | [clojure.term.colors :as colors] 6 | [clojure.tools.logging :as log] 7 | [dom-top.core :refer [letr]] 8 | [lambdaisland.regal :as regal]) 9 | (:import (java.io Closeable) 10 | (java.net InetSocketAddress) 11 | (java.util.concurrent Executors) 12 | (java.util.regex Pattern) 13 | (sun.misc Signal SignalHandler)) 14 | (:gen-class)) 15 | 16 | (declare return) 17 | 18 | (defn get-env [k v] 19 | (or (System/getenv k) 20 | (System/getProperty k v))) 21 | 22 | (defonce admin-settings (atom {})) 23 | 24 | (defn default-headers [] 25 | {:fail-before-code 503 26 | :fail-before-percentage 0 27 | 28 | :fail-after-percentage 0 29 | :fail-after-code 502 30 | 31 | :duplicate-percentage 0 32 | 33 | :delay-before-percentage 0 34 | :delay-before-ms 0 35 | 36 | :delay-after-percentage 0 37 | :delay-after-ms 0 38 | 39 | :match-uri "*" 40 | :match-uri-regex "*" 41 | :match-method "*" 42 | :match-uri-starts-with "*" 43 | 44 | :match-host "*" 45 | 46 | :match-header-name "*" 47 | :match-header-value "*" 48 | 49 | :destination-url nil}) 50 | 51 | (defn color-code [code] 52 | (cond (< code 400) 53 | (colors/green (str code)) 54 | 55 | (< code 500) 56 | (colors/yellow (str code)) 57 | 58 | :else 59 | (colors/red (str code)))) 60 | 61 | (defn parse-items [headers] 62 | (reduce-kv 63 | (fn [o k v] 64 | (let [vv (get headers k)] 65 | (if (string? vv) 66 | (if (or (nil? v) (string? v)) 67 | (assoc o k vv) 68 | (assoc o k (parse-long vv))) 69 | o))) 70 | {} 71 | (default-headers))) 72 | 73 | (defn env-settings [] 74 | (->> (default-headers) 75 | (mapcat (fn [[k v]] 76 | (let [env-k (-> (name k) 77 | (str/upper-case) 78 | (str/replace "-" "_"))] 79 | (when-let [env-v (get-env env-k nil)] 80 | [[k (if (or (nil? v) (string? v)) 81 | env-v 82 | (parse-long env-v))]])))) 83 | (into (sorted-map)))) 84 | 85 | (def header-prefix "x-mikkmokk-") 86 | 87 | (def body-trailer 88 | (if 89 | (= "true" (System/getenv "MIKKMOKK_DEVELOPMENT")) 90 | "\n" 91 | "")) 92 | 93 | (defn downcase-headers [headers] 94 | (update-keys headers str/lower-case)) 95 | 96 | (defn get-mikkmokk-keys [headers] 97 | (reduce-kv 98 | (fn [o k v] 99 | (if-not (str/starts-with? k header-prefix) 100 | o 101 | (if (not (contains? (default-headers) (keyword (subs k (count header-prefix))))) 102 | (do 103 | (log/warn "Unknown header:" k ", ignoring") 104 | o) 105 | (assoc o (keyword (subs k (count header-prefix))) v)))) 106 | {} 107 | headers)) 108 | 109 | (defn parse-headers-str-map [headers] 110 | (-> 111 | (downcase-headers headers) 112 | (get-mikkmokk-keys) 113 | (parse-items))) 114 | 115 | (defn parse-headers [env headers] 116 | (merge-with (fn [a b] (or b a)) 117 | (default-headers) 118 | env 119 | @admin-settings 120 | (parse-headers-str-map headers))) 121 | 122 | 123 | (defn json-kv [k v] 124 | (str "\"" 125 | (if (keyword? k) (name k) k) 126 | "\":" 127 | (cond (int? v) 128 | v 129 | (nil? v) 130 | "null" 131 | :else 132 | (str "\"" v "\"")))) 133 | 134 | (defn matches-uri? [string-pat uri] 135 | (cond 136 | (= "*" string-pat) 137 | true 138 | (= string-pat uri) 139 | true 140 | :else 141 | false)) 142 | 143 | (defn matches-uri-regex? [pat uri] 144 | (cond 145 | (= "*" pat) 146 | true 147 | (re-matches (Pattern/compile pat) uri) 148 | true 149 | :else 150 | false)) 151 | 152 | (defn matches-uri-starts-with? [s substr] 153 | (if (= "*" substr) 154 | true 155 | (str/starts-with? s substr))) 156 | 157 | (defn matches-method? [string-method method-kw] 158 | (cond 159 | (= "*" string-method) 160 | true 161 | (= (str/upper-case string-method) (str/upper-case (name method-kw))) 162 | true 163 | :else 164 | false)) 165 | 166 | (defn match-header-kv? [given-headers {:keys [match-header-name match-header-value]}] 167 | (if (or (= "*" match-header-name) 168 | (= "*" match-header-value)) 169 | true 170 | (= match-header-value (get given-headers match-header-name)))) 171 | 172 | (defn single-request [request-method url headers body] 173 | (try 174 | @(http/request 175 | {:request-method request-method 176 | :headers headers 177 | :body body 178 | :url url}) 179 | (catch Throwable t 180 | (let [m (ex-data t)] 181 | (if (and (map? m) (contains? m :status) (contains? m :headers) (contains? m :body)) 182 | m 183 | (do 184 | (log/warn t "Unexpected error when" request-method "for" (str/upper-case (name request-method)) url) 185 | {:status 500 186 | :headers {"content-type" "application/json"} 187 | :body (str "{" 188 | (json-kv "error" "unexpected-error") 189 | "," 190 | (json-kv "url" url) 191 | "}" 192 | body-trailer)})))))) 193 | 194 | (defn make-request [match? duplicate-percentage request-method uri url headers body] 195 | (let [req1 (future (single-request request-method url headers body)) 196 | duplicate? (and (> duplicate-percentage (rand-int 100)) match?) 197 | req2 (future (when duplicate? 198 | (single-request request-method url headers body))) 199 | resp1 @req1 200 | resp2 @req2] 201 | (if duplicate? 202 | (if (not= (:status resp1) (:status resp2)) 203 | (log/info "Duplicate request returned different HTTP status codes" (color-code (:status resp1)) "vs" (color-code (:status resp2)) "for" (str/upper-case (name request-method)) url) 204 | (log/info "Duplicate request returned identical HTTP status code" (color-code (:status resp1)) "for" (str/upper-case (name request-method)) url)) 205 | (log/debug "No duplicate request")) 206 | (rand-nth (filterv some? [resp1 resp2])))) 207 | 208 | (defn destination-url->host [destination-url] 209 | (second (str/split destination-url (re-pattern (Pattern/quote "://"))))) 210 | 211 | (defn destination-url->scheme [destination-url] 212 | (first (str/split destination-url (re-pattern (Pattern/quote "://"))))) 213 | 214 | (defn matches-host? [match-host destination-url] 215 | (when destination-url 216 | (if (= "*" match-host) 217 | true 218 | (= (destination-url->host destination-url) match-host)))) 219 | 220 | (defn matches? [{:keys [request-method uri headers]} 221 | {:keys [destination-url]} 222 | {:keys [match-uri match-uri-regex match-host match-uri-starts-with match-method] :as parsed-headers}] 223 | (and (matches-uri? match-uri uri) 224 | (matches-uri-regex? match-uri-regex uri) 225 | (matches-host? match-host destination-url) 226 | (matches-uri-starts-with? uri match-uri-starts-with) 227 | (matches-method? match-method request-method) 228 | (match-header-kv? headers parsed-headers))) 229 | 230 | (defn maybe-disj-one-off [request def-headers one-off-set] 231 | (let [match (->> one-off-set 232 | (filter (partial matches? request def-headers)) 233 | (first))] 234 | (disj one-off-set match))) 235 | 236 | (defn maybe-pop-one-off! [one-off request def-headers] 237 | (let [[old new] (swap-vals! one-off (partial maybe-disj-one-off request def-headers))] 238 | (if-some [found (first (set/difference old new))] 239 | (assoc found :destination-url (:destination-url def-headers)) 240 | def-headers))) 241 | 242 | (defn rewrite-response-headers [client-headers remote-headers] 243 | (when-let [acao (get remote-headers "access-control-allow-origin")] 244 | (when-let [origin (get client-headers "origin")] 245 | (log/debug "Rewriting" (json-kv "access-control-allow-origin" acao) "to" origin) 246 | {"access-control-allow-origin" origin}))) 247 | 248 | (defn handler [{:keys [env one-off]} {:keys [request-method uri headers body] :as request}] 249 | (letr [{:keys [fail-before-percentage 250 | fail-before-code 251 | fail-after-percentage 252 | fail-after-code 253 | delay-before-percentage 254 | delay-before-ms 255 | delay-after-percentage 256 | delay-after-ms 257 | duplicate-percentage 258 | destination-url] :as parsed-headers} (maybe-pop-one-off! one-off request (parse-headers env headers)) 259 | _ (when (empty? destination-url) 260 | (let [error-code 500] 261 | (log/warn "HTTP" (color-code error-code) (str/upper-case (name request-method)) 262 | uri "Missing destination-url, returning" (color-code error-code)) 263 | (return {:status error-code 264 | :headers {"content-type" "application/json"} 265 | :body (str "{" (json-kv "error" "missing-destination-url") "}" body-trailer)}))) 266 | method-uri-from (str (str/upper-case (name request-method)) " " uri " from " (destination-url->host destination-url)) 267 | dest-headers (-> headers 268 | (assoc "host" (destination-url->host destination-url)) 269 | (merge 270 | (when (not-empty (get headers "origin")) 271 | {"origin" (str (destination-url->scheme destination-url) 272 | "://" 273 | (destination-url->host destination-url))}))) 274 | url (str destination-url uri) 275 | match? (matches? request parsed-headers parsed-headers) 276 | delay-before-ms (if (and (> delay-before-percentage (rand-int 100)) match?) 277 | delay-before-ms 278 | 0) 279 | delay-after-ms (if (and (> delay-after-percentage (rand-int 100)) match?) 280 | delay-after-ms 281 | 0) 282 | _ (when (pos-int? delay-before-ms) 283 | (log/info "before-delay" delay-before-ms "ms") 284 | (Thread/sleep delay-before-ms)) 285 | _ (when (and (> fail-before-percentage (rand-int 100)) match?) 286 | (log/info "HTTP" (color-code fail-before-code) method-uri-from "fail-before") 287 | (return {:status fail-before-code 288 | :headers {"content-type" "application/json"} 289 | :body (str "{" (json-kv "error" "fail-before") "}" body-trailer)})) 290 | {:keys [headers status body]} (-> (make-request match? duplicate-percentage request-method uri url dest-headers body) 291 | (update :headers (fn [remote-headers] (merge 292 | remote-headers 293 | (rewrite-response-headers headers remote-headers))))) 294 | _ (when (pos-int? delay-after-ms) 295 | (log/info "delay-after" delay-after-ms "ms") 296 | (Thread/sleep delay-after-ms)) 297 | _ (when (and (> fail-after-percentage (rand-int 100)) match?) 298 | (log/info "HTTP" (color-code fail-after-code) method-uri-from "fail-after. Destination response code:" (color-code status)) 299 | (return {:status fail-after-code 300 | :headers {"content-type" "application/json"} 301 | :body (str "{" (json-kv "error" "fail-after") "," 302 | (json-kv "destination-response-code" status) "}" 303 | body-trailer)})) 304 | _ (if (or (= 0 305 | fail-before-percentage 306 | fail-after-percentage 307 | duplicate-percentage 308 | delay-before-percentage 309 | delay-after-percentage) 310 | (not match?)) 311 | (log/info "HTTP" (color-code status) (str method-uri-from ". No match / all percentages were zero.")) 312 | (log/info "HTTP" (color-code status) method-uri-from))] 313 | {:status status 314 | :headers headers 315 | :body body})) 316 | 317 | (def fwd-http 318 | (regal/regex 319 | [:cat 320 | [:alt 321 | "/mikkmokk-fwd-" 322 | "/mikkmokk-forward-"] 323 | [:capture 324 | [:alt "http" "https"]] 325 | "/" 326 | [:capture 327 | [:+ [:not \/]]] 328 | [:?? [:capture 329 | [:cat "/" 330 | [:* :any]]]] 331 | :end])) 332 | 333 | (comment 334 | (re-find fwd-http "/mikkmokk-fwd-http/pvo-backend-service.private.nsd.no")) 335 | 336 | (comment 337 | (re-find fwd-http "/mikkmokk-fwd-http/pvo-backend-service.private.nsd.no/api/w00t")) 338 | 339 | (defn outer-handler [cfg {:keys [uri] :as request}] 340 | (if-let [[_ scheme host uri] (not-empty (re-find fwd-http uri))] 341 | (let [uri (or uri "/")] 342 | (handler cfg (-> request 343 | (assoc :uri uri) 344 | (update :headers downcase-headers) 345 | (assoc-in [:headers "x-mikkmokk-destination-url"] (str scheme "://" host))))) 346 | (handler cfg request))) 347 | 348 | (defn admin-map->response [env adm] 349 | (let [adm (into (sorted-map) (merge-with (fn [a b] (or b a)) 350 | (default-headers) 351 | env 352 | adm))] 353 | {:status 200 354 | :headers {"content-type" "application/json"} 355 | :body (str "{" 356 | (str/join ",\n " (mapv (fn [[k v]] (json-kv k v)) adm)) 357 | "}" body-trailer)})) 358 | 359 | (defn admin-handler [{:keys [one-off env]} {:keys [headers uri request-method]}] 360 | (cond 361 | (and (= request-method :post) (= uri "/api/v1/update")) 362 | (admin-map->response env (swap! admin-settings merge (parse-headers-str-map headers))) 363 | 364 | (and (= request-method :post) (= uri "/api/v1/one-off")) 365 | (do 366 | (swap! one-off conj (-> (merge-with 367 | (fn [a b] (or b a)) 368 | {:one-off/id (random-uuid)} 369 | (default-headers) 370 | (parse-headers-str-map headers)) 371 | (dissoc :destination-url))) 372 | {:status 200 373 | :headers {"content-type" "application/json"} 374 | :body (str "{" 375 | (json-kv "service" "mikkmokk") "," 376 | (json-kv "message" "Added one-off") 377 | "}" body-trailer)}) 378 | 379 | (and (= request-method :post) (= uri "/api/v1/list-headers")) 380 | (do 381 | (doseq [header-key (sort (keys headers))] 382 | (when (str/starts-with? (str/lower-case header-key) "x-mikkmokk-") 383 | (log/info "x-mikkmokk- Header" header-key "=>" (get headers header-key)))) 384 | (doseq [header-key (sort (keys headers))] 385 | (when (not (str/starts-with? (str/lower-case header-key) "x-mikkmokk-")) 386 | (log/info "Other header" header-key "=>" (get headers header-key)))) 387 | {:status 200 388 | :headers {"content-type" "application/json"} 389 | :body (str "[" 390 | (str/join ", " 391 | (mapv #(str "\"" % "\"") 392 | (sort (keys headers)))) 393 | "]" 394 | body-trailer)}) 395 | 396 | (and (= request-method :post) (= uri "/api/v1/reset")) 397 | (admin-map->response env (reset! admin-settings (parse-headers-str-map headers))) 398 | 399 | (and (= request-method :get) (= uri "/api/v1/list")) 400 | (admin-map->response env @admin-settings) 401 | 402 | (and (= request-method :get) (= uri "/")) 403 | {:status 200 404 | :headers {"content-type" "application/json"} 405 | :body (str "{" 406 | (json-kv "service" "mikkmokk") 407 | "}" body-trailer)} 408 | 409 | (and (= request-method :get) (contains? #{"/health" "/healthcheck"} uri)) 410 | {:status 200 411 | :headers {"content-type" "application/json"} 412 | :body (str "{" 413 | (json-kv "service" "mikkmokk") "," 414 | (json-kv "status" "healthy") 415 | "}" body-trailer)} 416 | 417 | :else {:status 404 418 | :headers {"content-type" "application/json"} 419 | :body (str "{" (json-kv "message" "not-found") "}" body-trailer)})) 420 | 421 | (defonce servers (atom {})) 422 | 423 | (defn stop! [curr] 424 | (doseq [[nam inst] (into (sorted-map) curr)] 425 | (when (and inst (instance? Closeable inst)) 426 | (try 427 | (.close ^Closeable inst) 428 | (catch Throwable t 429 | (log/warn "Could not shutdown" (name nam) ":" (ex-message t)))) 430 | (log/info "Stopped" (name nam) "server"))) 431 | nil) 432 | 433 | (defn run-main [_] 434 | (let [proxy-bind (get-env "PROXY_BIND" "127.0.0.1") 435 | proxy-port (get-env "PROXY_PORT" "8080") 436 | admin-bind (get-env "ADMIN_BIND" "127.0.0.1") 437 | admin-port (get-env "ADMIN_PORT" "7070") 438 | one-off (atom #{}) 439 | env (env-settings) 440 | cfg {:one-off one-off 441 | :env env}] 442 | (swap! servers 443 | (fn [curr] 444 | (stop! curr) 445 | {:admin (http/start-server (fn [req] (admin-handler cfg req)) 446 | {:executor (Executors/newFixedThreadPool 8) 447 | :socket-address (InetSocketAddress. ^String admin-bind (parse-long admin-port))}) 448 | :proxy (http/start-server (fn [req] (outer-handler cfg req)) 449 | {:executor (Executors/newFixedThreadPool 256) 450 | :socket-address (InetSocketAddress. ^String proxy-bind (parse-long proxy-port))})})) 451 | (log/info "Started admin server at" (str admin-bind ":" admin-port)) 452 | (log/info "Started proxy server at" (str proxy-bind ":" proxy-port)) 453 | (doseq [[k v] (into (sorted-map) env)] 454 | (log/info "env setting" k v)))) 455 | 456 | (defn -main 457 | "Main method used to start the system from a JAR file." 458 | [& _args] 459 | (let [p (promise)] 460 | (Signal/handle 461 | (Signal. "INT") 462 | (reify SignalHandler 463 | (handle [_ _] 464 | (log/debug "Received SIGINT") 465 | (deliver p :shutdown)))) 466 | (run-main nil) 467 | @p 468 | (swap! servers stop!) 469 | (shutdown-agents))) 470 | 471 | (comment 472 | (run-main {})) 473 | --------------------------------------------------------------------------------