├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── api ├── authorization.go ├── authorization_test.go ├── export_test.go ├── httplogger │ ├── LICENSE │ ├── logger.go │ ├── middlewares_test.go │ ├── wrap_writer.go │ └── wrap_writer_test.go ├── mock_test.go ├── routes.go └── server.go ├── boast.go ├── boast_test.go ├── build ├── Dockerfile ├── boast.toml ├── certbot-dns-01-pre-validation-hook.sh └── certbot-dns-01-renew-hook.sh ├── cmd └── boast │ └── main.go ├── config ├── config.go ├── config_test.go └── export_test.go ├── docs ├── boast-configuration.md ├── boast.png ├── deploying.md └── interacting.md ├── examples ├── bash_client │ └── client.sh └── config │ ├── development-example.toml │ └── production-example.toml ├── go.mod ├── go.sum ├── log ├── log.go └── log_test.go ├── receivers ├── dnsrcv │ ├── dnsrcv.go │ ├── dnsrcv_test.go │ ├── export_test.go │ └── mock_test.go └── httprcv │ ├── export_test.go │ ├── httprcv.go │ ├── httprcv_test.go │ └── mock_test.go ├── storage ├── export_test.go ├── heap.go ├── storage.go └── storage_test.go └── testdata ├── cert.pem └── key.pem /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO := go 2 | BIN := boast 3 | COV := cov.out 4 | RM_RF := rm -rf 5 | 6 | all: $(BIN) 7 | 8 | .PHONY: $(BIN) 9 | $(BIN): 10 | @echo "> Building $(BIN)..." 11 | $(GO) build -o $(BIN) cmd/boast/*.go 12 | @echo "> Done." 13 | 14 | .PHONY: test 15 | test: 16 | @echo "> Testing..." 17 | $(GO) test ./... 18 | @echo "> Done." 19 | 20 | test_verbose: 21 | @echo "> Testing..." 22 | $(GO) test -v ./... 23 | @echo "> Done." 24 | 25 | .PHONY: cover 26 | cover: 27 | @echo "> Generating coverage profile..." 28 | $(GO) test -covermode=atomic -coverprofile=$(COV) ./... 29 | @echo "> Done." 30 | 31 | .PHONY: cover_html 32 | cover_html: cover 33 | @echo "> Generating HTML output for coverage profile..." 34 | $(GO) tool cover -html=cov.out 35 | @echo "> Done." 36 | 37 | .PHONY: cover_clean 38 | cover_clean: 39 | $(RM_RF) $(COV) 40 | 41 | .PHONY: clean 42 | clean: 43 | $(RM_RF) $(BIN) 44 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | BOAST 2 | Copyright 2020-present Marco Pereira (@ciphermarco) 3 | 4 | This project includes a component (httplogger) derived from work licensed under 5 | the MIT License which can be found on {project_root}/api/httplogger/LICENSE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BOAST 2 | 3 | **BOAST** is the **B**OAST **O**utpost for **A**ppSec **T**esting: a server designed to receive and report Out-of-Band Application Security Testing (OAST) reactions. 4 | 5 | ``` 6 | ┌─────────────────────────┐ 7 | | BOAST ◄──┐ 8 | ┌─┤ (DNS, HTTP, HTTPS, ...) | | 9 | │ └─────────────────────────┘ │ 10 | │ │ 11 | Reactions │ │ Reactions 12 | │ │ 13 | │ │ 14 | │ │ 15 | ┌──────▼──────────┐ Payloads ┌────┴────┐ 16 | │ Testing client ├──────────────► Target │ 17 | └─────────────────┘ └─────────┘ 18 | ``` 19 | 20 | Some application security tests will only trigger out-of-band reactions from 21 | the tested applications. These reactions will not be sent as a response to 22 | the testing client and, due to their nature, will remain unseen when the 23 | client is behind a NAT. To clearly observe these reactions, another component 24 | is needed. This component must be freely reachable on the Internet and capable 25 | of communicating using various protocols across multiple ports for maximum 26 | impact. BOAST is that component. 27 | 28 | BOAST features DNS, HTTP, and HTTPS protocol receivers, each supporting multiple 29 | simultaneous ports. Implementing protocol receivers for new protocols or customising 30 | existing ones to better suit your needs is almost as simple as implementing the protocol 31 | interaction itself. 32 | 33 | ## Used By 34 | 35 | BOAST is used by projects such as: 36 | 37 | - [Zed Attack Proxy (ZAP)](https://www.zaproxy.org/) 38 | 39 | ## Documentation 40 | 41 | https://github.com/ciphermarco/boast/tree/master/docs 42 | -------------------------------------------------------------------------------- /api/authorization.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/ciphermarco/BOAST/log" 12 | "github.com/go-chi/render" 13 | ) 14 | 15 | func (env *env) authorize(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | auth := r.Header.Get("Authorization") 18 | const secretMaxSize = 44 19 | 20 | // 1. Check Authorization header is not empty 21 | if auth == "" { 22 | err := errors.New("the Authorization header is missing") 23 | render.Render(w, r, errUnauthorized(err)) 24 | return 25 | } 26 | 27 | // 2. Check Authorization header is in the format " " 28 | authSplit := strings.Split(auth, " ") 29 | if len(authSplit) != 2 { 30 | err := errors.New("wrong authorization format") 31 | render.Render(w, r, errUnauthorized(err)) 32 | return 33 | } 34 | 35 | // 3. Check Authorization type is correct (i.e. "Secret") and that 36 | // does not exceed the maximum accepted size in bytes 37 | authType := authSplit[0] 38 | b64secret := authSplit[1] 39 | if authType != "Secret" { 40 | err := errors.New("unsupported authorization type") 41 | render.Render(w, r, errUnauthorized(err)) 42 | return 43 | } else if base64.StdEncoding.DecodedLen(len(b64secret)) > secretMaxSize { 44 | err := fmt.Errorf("secret is too long; maximum is %d bytes of decoded content", secretMaxSize) 45 | render.Render(w, r, errUnauthorized(err)) 46 | return 47 | } 48 | 49 | // 4. Check Authorization is valid base64 50 | secret, err := base64.StdEncoding.DecodeString(b64secret) 51 | if err != nil { 52 | log.Debug("base64 error: %v", err) 53 | err := errors.New("base64 error") 54 | render.Render(w, r, errUnauthorized(err)) 55 | return 56 | } 57 | 58 | // 5. Generate a base32 URL-safe id via SetTest 59 | id, canary, err := env.strg.SetTest(secret) 60 | if id == "" || canary == "" || err != nil { 61 | log.Debug("set test error: %v", err) 62 | err := fmt.Errorf("could not create test") 63 | render.Render(w, r, errUnauthorized(err)) 64 | return 65 | } 66 | 67 | ctx := context.WithValue(r.Context(), idCtxKey, id) 68 | ctx = context.WithValue(ctx, canaryCtxKey, canary) 69 | next.ServeHTTP(w, r.WithContext(ctx)) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /api/authorization_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "math/rand" 11 | "net/http" 12 | "net/http/httptest" 13 | "os" 14 | "testing" 15 | "time" 16 | 17 | "github.com/ciphermarco/BOAST/api" 18 | "github.com/ciphermarco/BOAST/log" 19 | ) 20 | 21 | func TestMain(m *testing.M) { 22 | log.SetOutput(ioutil.Discard) 23 | os.Exit(m.Run()) 24 | } 25 | 26 | func TestAuthorizeSuccess(t *testing.T) { 27 | req, err := newEventsRequest() 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | req.Header.Add("Authorization", fmt.Sprintf("Secret %s", tB64TestSecret)) 32 | 33 | mockStrg := &mockStorage{} 34 | rr := httptest.NewRecorder() 35 | handler := api.NewTestAPI("/test-status", mockStrg) 36 | handler.ServeHTTP(rr, req) 37 | 38 | checkStatusCode(http.StatusOK, rr.Code, t) 39 | checkEventsBody(rr.Body, tTest.ID, 0, t) 40 | } 41 | 42 | func TestAuthorizeWithoutHeader(t *testing.T) { 43 | req, err := newEventsRequest() 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | // Two slightly broken headers 48 | req.Header.Add("Authorizatio", fmt.Sprintf("Secret %s", tB64TestSecret)) 49 | req.Header.Add("Authorization", fmt.Sprintf("Secret%s", tB64TestSecret)) 50 | 51 | mockStrg := &mockStorage{} 52 | rr := httptest.NewRecorder() 53 | handler := api.NewTestAPI("/test-status", mockStrg) 54 | handler.ServeHTTP(rr, req) 55 | 56 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 57 | checkEventsBody(rr.Body, "", 0, t) 58 | } 59 | 60 | func TestAuthorizeWithWrongHeaderFormat(t *testing.T) { 61 | req, err := newEventsRequest() 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | mockStrg := &mockStorage{} 67 | rr := httptest.NewRecorder() 68 | handler := api.NewTestAPI("/test-status", mockStrg) 69 | handler.ServeHTTP(rr, req) 70 | 71 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 72 | checkEventsBody(rr.Body, "", 0, t) 73 | } 74 | 75 | func TestAuthorizeWithWrongAuthType(t *testing.T) { 76 | req, err := newEventsRequest() 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | // Secrt instead of Secret 81 | req.Header.Add("Authorization", fmt.Sprintf("Secrt %s", tB64TestSecret)) 82 | 83 | mockStrg := &mockStorage{} 84 | rr := httptest.NewRecorder() 85 | handler := api.NewTestAPI("/test-status", mockStrg) 86 | handler.ServeHTTP(rr, req) 87 | 88 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 89 | checkEventsBody(rr.Body, "", 0, t) 90 | } 91 | 92 | func TestAuthorizeWithTooLongSecret(t *testing.T) { 93 | req, err := newEventsRequest() 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | maxSize := 44 98 | longSecret := base64.StdEncoding.EncodeToString(randBytes(maxSize + 1)) 99 | req.Header.Add("Authorization", fmt.Sprintf("Secret %s", longSecret)) 100 | 101 | mockStrg := &mockStorage{} 102 | rr := httptest.NewRecorder() 103 | handler := api.NewTestAPI("/test-status", mockStrg) 104 | handler.ServeHTTP(rr, req) 105 | 106 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 107 | checkEventsBody(rr.Body, "", 0, t) 108 | } 109 | 110 | func TestAuthorizeWithInvalidBase64(t *testing.T) { 111 | req, err := newEventsRequest() 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | invalidB64Secret := tB64TestSecret[1:] 116 | req.Header.Add("Authorization", fmt.Sprintf("Secret %s", invalidB64Secret)) 117 | 118 | mockStrg := &mockStorage{} 119 | rr := httptest.NewRecorder() 120 | handler := api.NewTestAPI("/test-status", mockStrg) 121 | handler.ServeHTTP(rr, req) 122 | 123 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 124 | checkEventsBody(rr.Body, "", 0, t) 125 | } 126 | 127 | type errMockStorage struct { 128 | mockStorage 129 | } 130 | 131 | func (e *errMockStorage) SetTest(secret []byte) (string, string, error) { 132 | return "", "", errors.New("fake error") 133 | } 134 | 135 | func TestAuthorizeWithSetTestError(t *testing.T) { 136 | req, err := newEventsRequest() 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | req.Header.Add("Authorization", fmt.Sprintf("Secret %s", tB64TestSecret)) 141 | 142 | mockStrg := &errMockStorage{} 143 | rr := httptest.NewRecorder() 144 | handler := api.NewTestAPI("/test-status", mockStrg) 145 | handler.ServeHTTP(rr, req) 146 | 147 | checkStatusCode(http.StatusUnauthorized, rr.Code, t) 148 | checkEventsBody(rr.Body, "", 0, t) 149 | } 150 | 151 | func newEventsRequest(headers ...map[string]string) (req *http.Request, err error) { 152 | req, err = http.NewRequest("GET", "/events", nil) 153 | if err != nil { 154 | return nil, err 155 | } 156 | return req, nil 157 | } 158 | 159 | func checkStatusCode(want int, got int, t *testing.T) { 160 | if want != got { 161 | t.Errorf("handler returned wrong status code: %v (want) != %v (got)", 162 | want, got) 163 | } 164 | } 165 | 166 | func checkEventsBody(buf *bytes.Buffer, wantID string, wantEventsLen int, t *testing.T) { 167 | res, err := unmarshalEventsResponse(buf) 168 | if err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | if res.ID != wantID { 173 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, res.ID) 174 | } 175 | if len(res.Events) != wantEventsLen { 176 | t.Errorf("wrong Events length: %v (want) != %v (got)", wantEventsLen, len(res.Events)) 177 | } 178 | } 179 | 180 | func unmarshalEventsResponse(buf *bytes.Buffer) (res mockEventsResponse, err error) { 181 | b, err := ioutil.ReadAll(buf) 182 | if err != nil { 183 | return res, err 184 | } 185 | if err = json.Unmarshal(b, &res); err != nil { 186 | return res, err 187 | } 188 | return res, err 189 | } 190 | 191 | func randBytes(l int) []byte { 192 | rand.Seed(time.Now().UnixNano()) 193 | b := make([]byte, l) 194 | for i := range b { 195 | b[i] = byte(rand.Intn(255)) 196 | } 197 | return b 198 | } 199 | -------------------------------------------------------------------------------- /api/export_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | app "github.com/ciphermarco/BOAST" 7 | "github.com/ciphermarco/BOAST/log" 8 | ) 9 | 10 | type ExportAPI struct { 11 | http.Handler 12 | } 13 | 14 | func NewTestAPI(statusPath string, strg app.Storage) *ExportAPI { 15 | handler, err := api("", statusPath, strg) 16 | if err != nil { 17 | log.Fatalln(err) 18 | } 19 | return &ExportAPI{ 20 | Handler: handler, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/httplogger/LICENSE: -------------------------------------------------------------------------------- 1 | Package httplogger is a fork of the go-chi router's middleware package. Thus, the 2 | original MIT License below applies. 3 | 4 | Note: the original code is more useful for general imports since this is mostly the 5 | result of removing some of the parts that are not being used here, making it less general. 6 | 7 | So, check go-chi middlewares: https://github.com/go-chi/chi/tree/master/middleware 8 | 9 | ORIGINAL CODE LICENSE: 10 | 11 | Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. 12 | 13 | MIT License 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of 16 | this software and associated documentation files (the "Software"), to deal in 17 | the Software without restriction, including without limitation the rights to 18 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 19 | the Software, and to permit persons to whom the Software is furnished to do so, 20 | subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in all 23 | copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 27 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 28 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 29 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 30 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | Extensions of the original work are copyright (c) 2020-present Marco Pereira (@ciphermarco). 33 | -------------------------------------------------------------------------------- /api/httplogger/logger.go: -------------------------------------------------------------------------------- 1 | package httplogger 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/ciphermarco/BOAST/log" 11 | ) 12 | 13 | var ( 14 | // LogEntryCtxKey is the context.Context key to store the request log entry. 15 | LogEntryCtxKey = &contextKey{"LogEntry"} 16 | 17 | // DefaultLogger is called by the Logger middleware handler to log each request. 18 | // Its made a package-level variable so that it can be reconfigured for custom 19 | // logging configurations. 20 | DefaultLogger = RequestLogger(&DefaultLogFormatter{}) 21 | ) 22 | 23 | // contextKey is a value for use with context.WithValue. It's used as a pointer so it 24 | // fits in an interface{} without allocation. This technique for defining context keys 25 | // was copied from Go 1.7's new use of context in net/http. 26 | type contextKey struct { 27 | name string 28 | } 29 | 30 | func (k *contextKey) String() string { 31 | return "httplogger context value " + k.name 32 | 33 | } 34 | 35 | // Logger is a middleware that logs the start and end of each request, along with some 36 | // useful data about what was requested, what the response status was, and how long it 37 | // took to return. When standard output is a TTY, Logger will print in color, otherwise 38 | // it will print in black and white. Logger prints a request ID if one is provided. 39 | // 40 | // Alternatively, look at https://github.com/goware/httplog for a more in-depth http 41 | // logger with structured logging support. 42 | func Logger(next http.Handler) http.Handler { 43 | return DefaultLogger(next) 44 | } 45 | 46 | // RequestLogger returns a logger handler using a custom LogFormatter. 47 | func RequestLogger(f LogFormatter) func(next http.Handler) http.Handler { 48 | return func(next http.Handler) http.Handler { 49 | fn := func(w http.ResponseWriter, r *http.Request) { 50 | entry := f.NewLogEntry(r) 51 | ww := NewWrapResponseWriter(w, r.ProtoMajor) 52 | 53 | t1 := time.Now() 54 | defer func() { 55 | entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), nil) 56 | }() 57 | 58 | next.ServeHTTP(ww, WithLogEntry(r, entry)) 59 | } 60 | return http.HandlerFunc(fn) 61 | } 62 | } 63 | 64 | // LogFormatter initiates the beginning of a new LogEntry per request. 65 | // See DefaultLogFormatter for an example implementation. 66 | type LogFormatter interface { 67 | NewLogEntry(r *http.Request) LogEntry 68 | } 69 | 70 | // LogEntry records the final log when a request completes. 71 | // See defaultLogEntry for an example implementation. 72 | type LogEntry interface { 73 | Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) 74 | } 75 | 76 | // GetLogEntry returns the in-context LogEntry for a request. 77 | // func GetLogEntry(r *http.Request) LogEntry { 78 | // entry, _ := r.Context().Value(LogEntryCtxKey).(LogEntry) 79 | // return entry 80 | // } 81 | 82 | // WithLogEntry sets the in-context LogEntry for a request. 83 | func WithLogEntry(r *http.Request, entry LogEntry) *http.Request { 84 | r = r.WithContext(context.WithValue(r.Context(), LogEntryCtxKey, entry)) 85 | return r 86 | } 87 | 88 | // DefaultLogFormatter is a simple logger that implements a LogFormatter. 89 | type DefaultLogFormatter struct{} 90 | 91 | // NewLogEntry creates a new LogEntry for the request. 92 | func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry { 93 | entry := &defaultLogEntry{ 94 | request: r, 95 | buf: &bytes.Buffer{}, 96 | } 97 | 98 | entry.buf.WriteString("\"") 99 | fmt.Fprintf(entry.buf, "%s ", r.Method) 100 | 101 | scheme := "http" 102 | if r.TLS != nil { 103 | scheme = "https" 104 | } 105 | fmt.Fprintf(entry.buf, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto) 106 | 107 | entry.buf.WriteString("from ") 108 | entry.buf.WriteString(r.RemoteAddr) 109 | entry.buf.WriteString(" - ") 110 | 111 | return entry 112 | } 113 | 114 | type defaultLogEntry struct { 115 | request *http.Request 116 | buf *bytes.Buffer 117 | } 118 | 119 | func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 120 | fmt.Fprintf(l.buf, "%03d", status) 121 | fmt.Fprintf(l.buf, " %dB", bytes) 122 | fmt.Fprintf(l.buf, "%s", elapsed) 123 | log.Info(l.buf.String()) 124 | } 125 | -------------------------------------------------------------------------------- /api/httplogger/middlewares_test.go: -------------------------------------------------------------------------------- 1 | package httplogger 2 | -------------------------------------------------------------------------------- /api/httplogger/wrap_writer.go: -------------------------------------------------------------------------------- 1 | package httplogger 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "net" 7 | "net/http" 8 | ) 9 | 10 | // NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to 11 | // hook into various parts of the response process. 12 | func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter { 13 | _, fl := w.(http.Flusher) 14 | 15 | bw := basicWriter{ResponseWriter: w} 16 | 17 | if protoMajor == 2 { 18 | _, ps := w.(http.Pusher) 19 | if fl && ps { 20 | return &http2FancyWriter{bw} 21 | } 22 | } else { 23 | _, hj := w.(http.Hijacker) 24 | _, rf := w.(io.ReaderFrom) 25 | if fl && hj && rf { 26 | return &httpFancyWriter{bw} 27 | } 28 | } 29 | if fl { 30 | return &flushWriter{bw} 31 | } 32 | 33 | return &bw 34 | } 35 | 36 | // WrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook 37 | // into various parts of the response process. 38 | type WrapResponseWriter interface { 39 | http.ResponseWriter 40 | // Status returns the HTTP status of the request, or 0 if one has not 41 | // yet been sent. 42 | Status() int 43 | // BytesWritten returns the total number of bytes sent to the client. 44 | BytesWritten() int 45 | // Tee causes the response body to be written to the given io.Writer in 46 | // addition to proxying the writes through. Only one io.Writer can be 47 | // tee'd to at once: setting a second one will overwrite the first. 48 | // Writes will be sent to the proxy before being written to this 49 | // io.Writer. It is illegal for the tee'd writer to be modified 50 | // concurrently with writes. 51 | Tee(io.Writer) 52 | // Unwrap returns the original proxied target. 53 | Unwrap() http.ResponseWriter 54 | } 55 | 56 | // basicWriter wraps a http.ResponseWriter that implements the minimal 57 | // http.ResponseWriter interface. 58 | type basicWriter struct { 59 | http.ResponseWriter 60 | wroteHeader bool 61 | code int 62 | bytes int 63 | tee io.Writer 64 | } 65 | 66 | func (b *basicWriter) WriteHeader(code int) { 67 | if !b.wroteHeader { 68 | b.code = code 69 | b.wroteHeader = true 70 | b.ResponseWriter.WriteHeader(code) 71 | } 72 | } 73 | 74 | func (b *basicWriter) Write(buf []byte) (int, error) { 75 | b.maybeWriteHeader() 76 | n, err := b.ResponseWriter.Write(buf) 77 | if b.tee != nil { 78 | _, err2 := b.tee.Write(buf[:n]) 79 | // Prefer errors generated by the proxied writer. 80 | if err == nil { 81 | err = err2 82 | } 83 | } 84 | b.bytes += n 85 | return n, err 86 | } 87 | 88 | func (b *basicWriter) maybeWriteHeader() { 89 | if !b.wroteHeader { 90 | b.WriteHeader(http.StatusOK) 91 | } 92 | } 93 | 94 | func (b *basicWriter) Status() int { 95 | return b.code 96 | } 97 | 98 | func (b *basicWriter) BytesWritten() int { 99 | return b.bytes 100 | } 101 | 102 | func (b *basicWriter) Tee(w io.Writer) { 103 | b.tee = w 104 | } 105 | 106 | func (b *basicWriter) Unwrap() http.ResponseWriter { 107 | return b.ResponseWriter 108 | } 109 | 110 | type flushWriter struct { 111 | basicWriter 112 | } 113 | 114 | func (f *flushWriter) Flush() { 115 | f.wroteHeader = true 116 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 117 | fl.Flush() 118 | } 119 | 120 | var _ http.Flusher = &flushWriter{} 121 | 122 | // httpFancyWriter is a HTTP writer that additionally satisfies 123 | // http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case 124 | // of wrapping the http.ResponseWriter that package http gives you, in order to 125 | // make the proxied object support the full method set of the proxied object. 126 | type httpFancyWriter struct { 127 | basicWriter 128 | } 129 | 130 | func (f *httpFancyWriter) Flush() { 131 | f.wroteHeader = true 132 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 133 | fl.Flush() 134 | } 135 | 136 | func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 137 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 138 | return hj.Hijack() 139 | } 140 | 141 | func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { 142 | return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) 143 | } 144 | 145 | func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { 146 | if f.basicWriter.tee != nil { 147 | n, err := io.Copy(&f.basicWriter, r) 148 | f.basicWriter.bytes += int(n) 149 | return n, err 150 | } 151 | rf := f.basicWriter.ResponseWriter.(io.ReaderFrom) 152 | f.basicWriter.maybeWriteHeader() 153 | n, err := rf.ReadFrom(r) 154 | f.basicWriter.bytes += int(n) 155 | return n, err 156 | } 157 | 158 | var _ http.Flusher = &httpFancyWriter{} 159 | var _ http.Hijacker = &httpFancyWriter{} 160 | var _ http.Pusher = &http2FancyWriter{} 161 | var _ io.ReaderFrom = &httpFancyWriter{} 162 | 163 | // http2FancyWriter is a HTTP2 writer that additionally satisfies 164 | // http.Flusher, and io.ReaderFrom. It exists for the common case 165 | // of wrapping the http.ResponseWriter that package http gives you, in order to 166 | // make the proxied object support the full method set of the proxied object. 167 | type http2FancyWriter struct { 168 | basicWriter 169 | } 170 | 171 | func (f *http2FancyWriter) Flush() { 172 | f.wroteHeader = true 173 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 174 | fl.Flush() 175 | } 176 | 177 | var _ http.Flusher = &http2FancyWriter{} 178 | -------------------------------------------------------------------------------- /api/httplogger/wrap_writer_test.go: -------------------------------------------------------------------------------- 1 | package httplogger 2 | 3 | import ( 4 | "crypto/tls" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "path" 9 | "runtime" 10 | "testing" 11 | "time" 12 | 13 | "golang.org/x/net/http2" 14 | ) 15 | 16 | // NOTE: we must import `golang.org/x/net/http2` in order to explicitly enable 17 | // http2 transports for certain tests. The runtime pkg does not have this dependency 18 | // though as the transport configuration happens under the hood on go 1.7+. 19 | 20 | var testdataDir string 21 | 22 | func init() { 23 | _, filename, _, _ := runtime.Caller(0) 24 | testdataDir = path.Join(path.Dir(filename), "../../testdata") 25 | } 26 | 27 | func TestWrapWriterHTTP2(t *testing.T) { 28 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | _, fl := w.(http.Flusher) 30 | if !fl { 31 | t.Fatal("request should have been a http.Flusher") 32 | } 33 | _, hj := w.(http.Hijacker) 34 | if hj { 35 | t.Fatal("request should not have been a http.Hijacker") 36 | } 37 | _, rf := w.(io.ReaderFrom) 38 | if rf { 39 | t.Fatal("request should not have been a io.ReaderFrom") 40 | } 41 | _, ps := w.(http.Pusher) 42 | if !ps { 43 | t.Fatal("request should have been a http.Pusher") 44 | } 45 | 46 | w.Write([]byte("OK")) 47 | }) 48 | 49 | wmw := func(next http.Handler) http.Handler { 50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | next.ServeHTTP(NewWrapResponseWriter(w, r.ProtoMajor), r) 52 | }) 53 | } 54 | 55 | server := http.Server{ 56 | Addr: ":7072", 57 | Handler: wmw(handler), 58 | } 59 | // By serving over TLS, we get HTTP2 requests 60 | go server.ListenAndServeTLS(testdataDir+"/cert.pem", testdataDir+"/key.pem") 61 | defer server.Close() 62 | // We need the server to start before making the request 63 | time.Sleep(100 * time.Millisecond) 64 | 65 | client := &http.Client{ 66 | Transport: &http2.Transport{ 67 | TLSClientConfig: &tls.Config{ 68 | // The certificates we are using are self signed 69 | InsecureSkipVerify: true, 70 | }, 71 | }, 72 | } 73 | 74 | resp, err := client.Get("https://localhost:7072") 75 | if err != nil { 76 | t.Fatalf("could not get server: %v", err) 77 | } 78 | if resp.StatusCode != 200 { 79 | t.Fatalf("non 200 response: %v", resp.StatusCode) 80 | } 81 | } 82 | 83 | func TestFlushWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 84 | f := &flushWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 85 | f.Flush() 86 | 87 | if !f.wroteHeader { 88 | t.Fatal("want Flush to have set wroteHeader=true") 89 | } 90 | } 91 | 92 | func TestHttpFancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 93 | f := &httpFancyWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 94 | f.Flush() 95 | 96 | if !f.wroteHeader { 97 | t.Fatal("want Flush to have set wroteHeader=true") 98 | } 99 | } 100 | 101 | func TestHttp2FancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 102 | f := &http2FancyWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 103 | f.Flush() 104 | 105 | if !f.wroteHeader { 106 | t.Fatal("want Flush to have set wroteHeader=true") 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /api/mock_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "errors" 7 | "time" 8 | 9 | app "github.com/ciphermarco/BOAST" 10 | ) 11 | 12 | var tB64TestSecret = "872k5eD/lGRbMZ3GqIPB0bUzqRjBlt1lhLH4+/42sKa=" 13 | 14 | var tTestSecret, _ = base64.StdEncoding.DecodeString(tB64TestSecret) 15 | 16 | var tTest = mockTest{ 17 | ID: "mpqhomfbxab55m5de32mywvfoy", 18 | Canary: "k2b27meg7dfifvxuxmnfnm24oa", 19 | Events: []app.Event{}, 20 | } 21 | 22 | type mockTest struct { 23 | ID string 24 | Canary string 25 | Events []app.Event 26 | } 27 | 28 | type mockEventsResponse struct { 29 | ID string `json:"id"` 30 | Canary string `json:"canary"` 31 | Events []app.Event `json:"events"` 32 | } 33 | 34 | type mockStorage struct{} 35 | 36 | func (s *mockStorage) SetTest(secret []byte) (id string, canary string, err error) { 37 | if eq := bytes.Compare(secret, tTestSecret); eq != 0 { 38 | return "", "", errors.New("mock test not found") 39 | } 40 | return tTest.ID, tTest.Canary, nil 41 | } 42 | 43 | func (s *mockStorage) LoadEvents(id string) (evts []app.Event, loaded bool) { 44 | mockEvt := app.Event{ 45 | ID: "TEST ID", 46 | Time: time.Now(), 47 | TestID: "TEST TestID", 48 | Receiver: "TEST Receiver", 49 | RemoteAddr: "203.0.113.113", 50 | Dump: "TEST DUMP", 51 | QueryType: "TEST QueryType", 52 | } 53 | evts = []app.Event{mockEvt} 54 | return evts, false 55 | } 56 | 57 | func (s *mockStorage) SearchTest(f func(k, v string) bool) (id string, canary string) { 58 | return "", "" 59 | } 60 | 61 | func (s *mockStorage) StoreEvent(evt app.Event) error { 62 | return nil 63 | } 64 | 65 | func (s *mockStorage) TotalTests() int { 66 | return 0 67 | } 68 | 69 | func (s *mockStorage) TotalEvents() int { 70 | return 0 71 | } 72 | 73 | func (s *mockStorage) StartExpire(err chan error) {} 74 | -------------------------------------------------------------------------------- /api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | app "github.com/ciphermarco/BOAST" 10 | "github.com/ciphermarco/BOAST/api/httplogger" 11 | "github.com/ciphermarco/BOAST/log" 12 | 13 | "github.com/go-chi/chi/v5" 14 | "github.com/go-chi/render" 15 | "github.com/prometheus/procfs" 16 | ) 17 | 18 | type env struct { 19 | strg app.Storage 20 | proc procfs.Proc 21 | domain string 22 | } 23 | 24 | func api(domain string, statusPath string, strg app.Storage) (http.Handler, error) { 25 | e := &env{strg: strg, domain: domain} 26 | r := chi.NewRouter() 27 | 28 | if e.domain != "" { 29 | r.Use(e.hostCheck) 30 | } 31 | r.Use(httplogger.Logger) 32 | r.Use(render.SetContentType(render.ContentTypeJSON)) 33 | 34 | r.Get("/", e.home) 35 | r.With(e.authorize).Get("/events", e.events) 36 | 37 | if statusPath != "" && statusPath != "/" { 38 | p, err := procfs.Self() 39 | if err != nil { 40 | return nil, err 41 | } 42 | e.proc = p 43 | r.Get(statusPath, e.status) 44 | } 45 | 46 | return r, nil 47 | } 48 | 49 | func (env *env) hostCheck(next http.Handler) http.Handler { 50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | hostHdr := []string{ 52 | // RFC 7239 53 | "Forwarded", 54 | // Popular but non-standard 55 | "X-Forwarded-Host", 56 | } 57 | host := r.Host 58 | for _, hdr := range hostHdr { 59 | if h := r.Header.Get(hdr); h != "" { 60 | host = h 61 | } 62 | } 63 | d := strings.Split(host, ":")[0] 64 | if strings.ToLower(d) != env.domain { 65 | http.Error(w, http.StatusText(404), 404) 66 | return 67 | } 68 | next.ServeHTTP(w, r) 69 | }) 70 | } 71 | 72 | func (env *env) home(w http.ResponseWriter, r *http.Request) { 73 | u := "https://github.com/ciphermarco/BOAST" 74 | h := fmt.Sprintf("BOAST API (learn more)", u) 75 | fmt.Fprint(w, h) 76 | } 77 | 78 | func (env *env) status(w http.ResponseWriter, r *http.Request) { 79 | statusErr := errors.New("could not access process status") 80 | check := func(i string, d string, err error) { 81 | if err != nil { 82 | log.Info(i) 83 | log.Debug("%s: %s", d, err) 84 | render.Render(w, r, errInternalServerError(statusErr)) 85 | return 86 | } 87 | } 88 | 89 | stat, err := env.proc.Stat() 90 | check("could not access process stat", "process stat error", err) 91 | 92 | fdLen, err := env.proc.FileDescriptorsLen() 93 | check("could not access open file descriptors", "file descriptors len error", err) 94 | 95 | limits, err := env.proc.Limits() 96 | check("could not access process limits", "process limits error", err) 97 | 98 | res := &statusResponse{ 99 | StoredTests: env.strg.TotalTests(), 100 | StoredEvents: env.strg.TotalEvents(), 101 | RSS: stat.ResidentMemory(), 102 | FDLen: fdLen, 103 | FDLimit: limits.OpenFiles, 104 | } 105 | render.Render(w, r, res) 106 | } 107 | 108 | type statusResponse struct { 109 | StoredTests int `json:"storedTests"` 110 | StoredEvents int `json:"storedEvents"` 111 | RSS int `json:"residentSetSizeBytes"` 112 | FDLen int `json:"openFileDescriptors"` 113 | FDLimit uint64 `json:"openFileDescriptorsLimit"` 114 | } 115 | 116 | func (res *statusResponse) Render(w http.ResponseWriter, r *http.Request) error { 117 | return nil 118 | } 119 | 120 | type ctxKey string 121 | 122 | var idCtxKey = ctxKey("id") 123 | var canaryCtxKey = ctxKey("canary") 124 | 125 | func (env *env) events(w http.ResponseWriter, r *http.Request) { 126 | id, idOk := r.Context().Value(idCtxKey).(string) 127 | canary, canaryOk := r.Context().Value(canaryCtxKey).(string) 128 | 129 | if !idOk || !canaryOk || id == "" || canary == "" { 130 | log.Info("API /events could not get authorization context keys from context") 131 | log.Debug("API /events got id from context of type %T", id) 132 | log.Debug("API /events got canary from context of type %T", canary) 133 | 134 | err := errors.New("internal authentication error") 135 | render.Render(w, r, errUnauthorized(err)) 136 | return 137 | } 138 | 139 | if events, exists := env.strg.LoadEvents(id); exists { 140 | res := &eventsResponse{ID: id, Canary: canary, Events: events} 141 | render.Render(w, r, res) 142 | } else { 143 | res := &eventsResponse{ID: id, Canary: canary, Events: []app.Event{}} 144 | render.Render(w, r, res) 145 | } 146 | } 147 | 148 | type eventsResponse struct { 149 | ID string `json:"id"` 150 | Canary string `json:"canary"` 151 | Events []app.Event `json:"events"` 152 | } 153 | 154 | func (res *eventsResponse) Render(w http.ResponseWriter, r *http.Request) error { 155 | return nil 156 | } 157 | 158 | type errResponse struct { 159 | Err error `json:"-"` 160 | HTTPStatusCode int `json:"-"` 161 | StatusText string `json:"status"` 162 | ErrorText string `json:"error,omitempty"` 163 | } 164 | 165 | func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error { 166 | render.Status(r, e.HTTPStatusCode) 167 | return nil 168 | } 169 | 170 | func errUnauthorized(err error) render.Renderer { 171 | return &errResponse{ 172 | Err: err, 173 | HTTPStatusCode: http.StatusUnauthorized, 174 | StatusText: "Unauthorized", 175 | ErrorText: err.Error(), 176 | } 177 | } 178 | 179 | func errInternalServerError(err error) render.Renderer { 180 | return &errResponse{ 181 | Err: err, 182 | HTTPStatusCode: http.StatusInternalServerError, 183 | StatusText: "Internal Server Error", 184 | ErrorText: err.Error(), 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | app "github.com/ciphermarco/BOAST" 11 | "github.com/ciphermarco/BOAST/log" 12 | ) 13 | 14 | // Server represents the API server. 15 | type Server struct { 16 | Host string 17 | Domain string 18 | Port int 19 | TLSPort int 20 | TLSCertPath string 21 | TLSKeyPath string 22 | StatusPath string 23 | Storage app.Storage 24 | } 25 | 26 | // ListenAndServe sets the necessary conditions for the underlying http.Server 27 | // to serve the API via HTTPS. 28 | // 29 | // Any errors are returned via the received channel. 30 | func (s *Server) ListenAndServe(err chan error) { 31 | tlsConfig := &tls.Config{ 32 | MinVersion: tls.VersionTLS12, 33 | PreferServerCipherSuites: true, 34 | CipherSuites: []uint16{ 35 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 36 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 37 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 38 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 39 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 40 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 41 | }, 42 | CurvePreferences: []tls.CurveID{ 43 | tls.CurveP256, tls.X25519, 44 | }, 45 | } 46 | 47 | addr := s.Addr(s.TLSPort) 48 | statusPath := ensureLeadingSlash(url.PathEscape(s.StatusPath)) 49 | r, e := api(s.Domain, statusPath, s.Storage) 50 | if e != nil { 51 | err <- e 52 | } 53 | 54 | srv := &http.Server{ 55 | Addr: addr, 56 | Handler: r, 57 | TLSConfig: tlsConfig, 58 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 59 | ReadTimeout: 5 * time.Second, 60 | WriteTimeout: 10 * time.Second, 61 | IdleTimeout: 120 * time.Second, 62 | } 63 | 64 | if statusPath != "" && statusPath != "/" { 65 | log.Info("Web API Server: status URL is https://%s%s", addr, statusPath) 66 | } 67 | log.Info("Web API Server: Listening on https://%s\n", addr) 68 | err <- srv.ListenAndServeTLS(s.TLSCertPath, s.TLSKeyPath) 69 | } 70 | 71 | // Addr returns an address in the format expected by http.Server. 72 | func (s *Server) Addr(port int) string { 73 | return s.Host + fmt.Sprintf(":%d", port) 74 | } 75 | 76 | func ensureLeadingSlash(s string) string { 77 | l := len(s) 78 | if l > 0 && s[0] != '/' { 79 | return "/" + s 80 | } 81 | return "/" 82 | } 83 | -------------------------------------------------------------------------------- /boast.go: -------------------------------------------------------------------------------- 1 | package boast 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base32" 6 | "encoding/json" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ciphermarco/BOAST/log" 11 | ) 12 | 13 | // Storage represents the BOAST's storage implementation. 14 | // It's implemented by any type that provides these methods so it can be easily swapped 15 | // by a DB or other kind of storage if needed. 16 | type Storage interface { 17 | SetTest(secret []byte) (id string, canary string, err error) 18 | SearchTest(f func(k, v string) bool) (id string, canary string) 19 | StoreEvent(evt Event) error 20 | LoadEvents(id string) (evts []Event, loaded bool) 21 | TotalTests() int 22 | TotalEvents() int 23 | StartExpire(err chan error) 24 | } 25 | 26 | // Event represents an interaction event. 27 | type Event struct { 28 | ID string `json:"id"` 29 | Time time.Time `json:"time"` 30 | TestID string `json:"testID"` 31 | Receiver string `json:"receiver"` 32 | RemoteAddr string `json:"remoteAddress,omitempty"` 33 | Dump string `json:"dump,omitempty"` 34 | QueryType string `json:"queryType,omitempty"` 35 | } 36 | 37 | // String satisfies the Stringer interface for pretty-printing Event. 38 | // This should only be used for debugging. 39 | func (e *Event) String() string { 40 | s, err := json.MarshalIndent(e, "", "\t") 41 | if err != nil { 42 | log.Debug("Event's String method error: %v", err) 43 | return "" 44 | } 45 | return string(s) 46 | } 47 | 48 | // NewEvent allocates a new Event struct and returns its copy. 49 | // The raison d'être of this function is to provide an easy interface to generate an 50 | // event with a standard ID without the caller having to deal with it. 51 | func NewEvent(testID, receiver, addr, dump string) (Event, error) { 52 | id, err := genEventID() 53 | if err != nil { 54 | return Event{}, err 55 | } 56 | 57 | return Event{ 58 | ID: id, 59 | Time: time.Now(), 60 | TestID: testID, 61 | Receiver: receiver, 62 | RemoteAddr: addr, 63 | Dump: dump, 64 | }, nil 65 | } 66 | 67 | // NewDNSEvent allocates a new Event using NewEvent but with the difference of recording 68 | // the passed DNS query type to keep more information for DNS queries. 69 | func NewDNSEvent(testID, receiver, addr, dump, qType string) (Event, error) { 70 | evt, err := NewEvent(testID, receiver, addr, dump) 71 | if err != nil { 72 | return evt, err 73 | } 74 | evt.QueryType = qType 75 | return evt, nil 76 | } 77 | 78 | // genEventID generates a random event ID. 79 | // The returned event ID is a 16 bytes long value base32 encoded by ToBase32. 80 | func genEventID() (string, error) { 81 | c := 16 82 | random := make([]byte, c) 83 | 84 | if _, err := rand.Read(random); err != nil { 85 | return "", err 86 | } 87 | 88 | res := ToBase32(random) 89 | return res, nil 90 | } 91 | 92 | // ToBase32 encodes b to the base32 format used by BOAST's components. 93 | func ToBase32(b []byte) string { 94 | enc := base32.StdEncoding.WithPadding(-1) 95 | res := enc.EncodeToString(b) 96 | res = strings.ToLower(res) 97 | return res 98 | } 99 | -------------------------------------------------------------------------------- /boast_test.go: -------------------------------------------------------------------------------- 1 | package boast_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | app "github.com/ciphermarco/BOAST" 9 | ) 10 | 11 | func TestNewEvent(t *testing.T) { 12 | want := app.Event{ 13 | ID: "TEST ID", 14 | Time: time.Now(), 15 | TestID: "TEST TestID", 16 | Receiver: "TEST Receiver", 17 | RemoteAddr: "TEST RemoteAddr", 18 | Dump: "TEST Dump", 19 | } 20 | got, err := app.NewEvent( 21 | "TEST TestID", 22 | "TEST Receiver", 23 | "TEST RemoteAddr", 24 | "TEST Dump", 25 | ) 26 | 27 | if err != nil { 28 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 29 | } 30 | if want.Time.UnixNano() >= got.Time.UnixNano() { 31 | t.Errorf("wrong Time: %v (want) != %v (got)", want.Time, got.Time) 32 | } 33 | 34 | want.Time = got.Time 35 | 36 | wantLenID := 26 37 | gotLenID := len(got.ID) 38 | if wantLenID != gotLenID { 39 | t.Errorf("wrong ID length: %v (want) != %v (got)", 40 | wantLenID, gotLenID) 41 | } 42 | 43 | want.ID = got.ID 44 | 45 | if !reflect.DeepEqual(want, got) { 46 | t.Errorf("wrong event") 47 | t.Errorf("Want:") 48 | t.Errorf("%+v", want) 49 | t.Errorf("Got:") 50 | t.Errorf("%+v", got) 51 | } 52 | } 53 | 54 | func TestNewDNSEvent(t *testing.T) { 55 | want := app.Event{ 56 | ID: "TEST ID", 57 | Time: time.Now(), 58 | TestID: "TEST TestID", 59 | Receiver: "TEST Receiver", 60 | RemoteAddr: "TEST RemoteAddr", 61 | Dump: "TEST Dump", 62 | QueryType: "TEST QueryType", 63 | } 64 | got, err := app.NewDNSEvent( 65 | "TEST TestID", 66 | "TEST Receiver", 67 | "TEST RemoteAddr", 68 | "TEST Dump", 69 | "TEST QueryType", 70 | ) 71 | 72 | if err != nil { 73 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 74 | } 75 | if want.Time.UnixNano() >= got.Time.UnixNano() { 76 | t.Errorf("wrong Time: %v (want) != %v (got)", want.Time, got.Time) 77 | } 78 | 79 | want.Time = got.Time 80 | 81 | wantLenID := 26 82 | gotLenID := len(got.ID) 83 | if wantLenID != gotLenID { 84 | t.Errorf("wrong ID length: %v (want) != %v (got)", 85 | wantLenID, gotLenID) 86 | } 87 | 88 | want.ID = got.ID 89 | 90 | if !reflect.DeepEqual(want, got) { 91 | t.Errorf("wrong event") 92 | t.Errorf("Want:") 93 | t.Errorf("%+v", want) 94 | t.Errorf("Got:") 95 | t.Errorf("%+v", got) 96 | } 97 | } 98 | 99 | func TestToBase32(t *testing.T) { 100 | want := map[string]string{ 101 | "smcdpjlzu": "onwwgzdqnjwhu5i", 102 | "bdwbpjiic": "mjshoytqnjuwsyy", 103 | "epizdfjvnjt": "mvygs6temzvhm3tkoq", 104 | "fahkwl": "mzqwq23xnq", 105 | "xfzc": "pbthuyy", 106 | "xvvdji": "pb3hmzdkne", 107 | "ufmuhvebnxr": "ovtg25liozswe3tyoi", 108 | "whelamjko": "o5ugk3dbnvvgw3y", 109 | "amcaabgp": "mfwwgylbmjtxa", 110 | "zkqsgvnkhs": "pjvxc43hozxgw2dt", 111 | "dcbf": "mrrwezq", 112 | "ceehfulcs": "mnswk2dgovwgg4y", 113 | "rstfckezdp": "ojzxiztdnnsxuzdq", 114 | "apbwwsoetv": "mfyge53xonxwk5dw", 115 | "dpmvb": "mryg25tc", 116 | "pgsl": "obtxg3a", 117 | "tajzyt": "orqwu6tzoq", 118 | "nufpbgwlqpxx": "nz2wm4dcm53wy4lqpb4a", 119 | "vprqw": "ozyhe4lx", 120 | "yhejthchj": "pfugk2tunbrwq2q", 121 | } 122 | 123 | for k, v := range want { 124 | got := app.ToBase32([]byte(k)) 125 | if v != got { 126 | t.Errorf("wrong base32: %v (want) != %v (got)", want, got) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | 3 | ENV CGO_ENABLED=0 \ 4 | GOOS=linux \ 5 | GOARCH=amd64 6 | 7 | WORKDIR /go/src/github.com/ciphermarco/BOAST 8 | 9 | COPY go.mod . 10 | COPY go.sum . 11 | RUN go mod download 12 | 13 | COPY ./build/boast.toml . 14 | COPY . . 15 | 16 | RUN apk update && apk upgrade && \ 17 | apk add --no-cache make 18 | RUN make test 19 | RUN make 20 | 21 | EXPOSE 53/udp 80 443 2096 8080 8443 22 | 23 | CMD ["./boast"] 24 | -------------------------------------------------------------------------------- /build/boast.toml: -------------------------------------------------------------------------------- 1 | # BOAST's production configuration to be used with the provided Dockerfile. 2 | # You want to give special attention to the commented parameters. 3 | # 4 | # Doc on how to use this with the provided Dockerfile (and more): 5 | # https://github.com/ciphermarco/boast/blob/master/docs/deploying.md#deploying-with-docker 6 | # 7 | [storage] 8 | # max_events = 1_000_000 9 | # max_events_by_test = 100 10 | # max_dump_size = "80KB" 11 | # hmac_key = "" 12 | 13 | [storage.expire] 14 | # ttl = "12h" 15 | # check_interval = "1h" 16 | # max_restarts = 100 17 | 18 | [api] 19 | host = "0.0.0.0" 20 | # domain = "example.com" 21 | tls_port = 2096 22 | tls_cert = "./tls/fullchain.pem" 23 | tls_key = "./tls/privkey.pem" 24 | 25 | [api.status] 26 | # url_path = "" 27 | 28 | [http_receiver] 29 | host = "0.0.0.0" 30 | ports = [80, 8080] 31 | # real_ip_header = "X-Real-IP" 32 | 33 | [http_receiver.tls] 34 | ports = [443, 8443] 35 | cert = "./tls/fullchain.pem" 36 | key = "./tls/privkey.pem" 37 | 38 | [dns_receiver] 39 | host = "0.0.0.0" 40 | ports = [53] 41 | # domain = "example.com" 42 | # public_ip = "203.0.113.77" 43 | -------------------------------------------------------------------------------- /build/certbot-dns-01-pre-validation-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This Let's Encrypt pre-validation hook is to be used for the DNS-01 challenge with 3 | # BOAST's main domain. It's made to work with the Dockerfile in this directory and may 4 | # need some changes for customised use cases. 5 | # 6 | # This hook will only be run if the certificate is due for renewal, so certbot can be 7 | # run frequently (e.g. as a cron job) without unnecessarily stopping BOAST. 8 | # 9 | # Doc on how to use this with the provided Dockerfile (and more): 10 | # https://github.com/ciphermarco/boast/blob/master/docs/deploying.md#deploying-with-docker 11 | # 12 | if [ -z "$CERTBOT_VALIDATION" ] 13 | then 14 | echo "error: validation is empty" 15 | exit -1 16 | fi 17 | 18 | _docker_boast_img="boastimg" 19 | _docker_boast_container="boastmain" 20 | _docker_boast_dns_container="boastdns" 21 | _docker_boast_bin="/go/src/github.com/ciphermarco/BOAST/boast" 22 | 23 | # Ignoring errors with `|| true` in case containers are not running or do not exist. 24 | 25 | # Make sure everything is stopped 26 | docker stop ${_docker_boast_container} || true 27 | docker stop ${_docker_boast_dns_container} || true 28 | 29 | # Make sure the BOAST's DNS temporary container does not exist. 30 | docker container rm ${_docker_boast_dns_container} || true 31 | 32 | # Run the DNS receiver with the challenge's TXT record. 33 | docker run -d --name ${_docker_boast_dns_container} -p 53:53/udp ${_docker_boast_img} ${_docker_boast_bin} -dns_only -dns_txt ${CERTBOT_VALIDATION} 34 | -------------------------------------------------------------------------------- /build/certbot-dns-01-renew-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This Let's Encrypt post-validation hook is to be used for the DNS-01 challenge with 3 | # BOAST's main domain. It's made to work with the Dockerfile in this directory and may 4 | # need some changes for customised use cases. 5 | # 6 | # This hook will only be run if the certificate is due for renewal, so certbot can be 7 | # run frequently (e.g. as a cron job) without unnecessarily stopping BOAST. 8 | # 9 | # Doc on how to use this with the provided Dockerfile and more: 10 | # https://github.com/ciphermarco/boast/blob/master/docs/deploying.md#deploying-with-docker 11 | # 12 | if [ -z "$RENEWED_LINEAGE" ] 13 | then 14 | echo "error: renewed lineage is empty" 15 | exit -1 16 | fi 17 | 18 | _docker_boast_img="boastimg" 19 | _docker_boast_container="boastmain" 20 | _docker_boast_dns_container="boastdns" 21 | _tls_certificate="${RENEWED_LINEAGE}/fullchain.pem" 22 | _tls_privkey="${RENEWED_LINEAGE}/privkey.pem" 23 | _boast_tls="$HOME/boast/tls" # <- Change this if necessary 24 | 25 | # Ignoring errors with `|| true` in case containers are not running or do not exist. 26 | 27 | # Make sure everything is stopped. 28 | docker stop ${_docker_boast_container} || true 29 | docker stop ${_docker_boast_dns_container} || true 30 | 31 | # Make sure the BOAST container does not exist. 32 | docker container rm ${_docker_boast_container} || true 33 | 34 | # Copy TLS files to BOAST's TLS directory. 35 | mkdir -p ${_boast_tls} 36 | cp ${_tls_certificate} ${_tls_privkey} ${_boast_tls} 37 | 38 | # Run the BOAST's main container. 39 | docker run -d --name ${_docker_boast_container} -p 53:53/udp -p 80:80 -p 443:443 -p 2096:2096 -p 8080:8080 -p 8443:8443 -v ${_boast_tls}:/go/src/github.com/ciphermarco/BOAST/tls ${_docker_boast_img} 40 | -------------------------------------------------------------------------------- /cmd/boast/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/ciphermarco/BOAST/api" 10 | "github.com/ciphermarco/BOAST/config" 11 | "github.com/ciphermarco/BOAST/log" 12 | "github.com/ciphermarco/BOAST/receivers/dnsrcv" 13 | "github.com/ciphermarco/BOAST/receivers/httprcv" 14 | "github.com/ciphermarco/BOAST/storage" 15 | 16 | "github.com/BurntSushi/toml" 17 | ) 18 | 19 | const program = "BOAST" 20 | const version = "v1.0.0" 21 | const author = "Marco Pereira (ciphermarco)" 22 | 23 | var ( 24 | prognver = fmt.Sprintf("%s %s", program, version) 25 | banner = fmt.Sprintf("%s (by %s)\n", prognver, author) 26 | cfgPath string 27 | logLevel int 28 | logPath string 29 | dnsOnly bool 30 | dnsTxt string 31 | showVer bool 32 | ) 33 | 34 | func init() { 35 | flag.Usage = func() { 36 | fmt.Fprintf(os.Stderr, "%s\n", banner) 37 | fmt.Fprintf(os.Stderr, "Usage:\n") 38 | fmt.Fprintf(os.Stderr, "%s [OPTION...]\n\n", os.Args[0]) 39 | flag.PrintDefaults() 40 | } 41 | flag.StringVar(&cfgPath, "config", "boast.toml", "TOML configuration file") 42 | flag.IntVar(&logLevel, "log_level", 1, "Set the logging level (0=DEBUG|1=INFO)") 43 | flag.StringVar(&logPath, "log_file", "", "Path to log file") 44 | flag.BoolVar(&dnsOnly, "dns_only", false, "Run only the DNS receiver and its dependencies") 45 | flag.StringVar(&dnsTxt, "dns_txt", "", "DNS receiver's TXT record") 46 | flag.BoolVar(&showVer, "v", false, "Print program version and quit") 47 | flag.Parse() 48 | 49 | if showVer { 50 | fmt.Fprintf(os.Stderr, "%s", banner) 51 | os.Exit(0) 52 | } 53 | 54 | log.SetLevel(logLevel) 55 | if logPath != "" { 56 | f, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) 57 | if err != nil { 58 | log.Fatalln("Failed to open log file:", err) 59 | } 60 | log.SetOutput(f) 61 | } 62 | } 63 | 64 | func main() { 65 | log.Info("Starting %s", prognver) 66 | 67 | tomlData, err := ioutil.ReadFile(cfgPath) 68 | if err != nil { 69 | log.Fatalln("Failed to read configuration:", err) 70 | } 71 | var cfg config.Config 72 | if err = toml.Unmarshal(tomlData, &cfg); err != nil { 73 | log.Fatalln("Failed to parse configuration:", err) 74 | } 75 | 76 | strg, err := storage.New(&storage.Config{ 77 | TTL: cfg.Strg.Expire.TTL.Value(), 78 | CheckInterval: cfg.Strg.Expire.CheckInterval.Value(), 79 | MaxRestarts: cfg.Strg.Expire.MaxRestarts, 80 | MaxEvents: cfg.Strg.MaxEvents, 81 | MaxEventsByTest: cfg.Strg.MaxEventsByTest, 82 | MaxDumpSize: cfg.Strg.MaxDumpSize.Value(), 83 | HMACKey: cfg.Strg.HMACKey, 84 | }) 85 | if err != nil { 86 | log.Fatalln("Failed to create storage:", err) 87 | } 88 | 89 | apiSrv := &api.Server{ 90 | Host: cfg.API.Host, 91 | Domain: cfg.API.Domain, 92 | TLSPort: cfg.API.TLSPort, 93 | TLSCertPath: cfg.API.TLSCertPath, 94 | TLSKeyPath: cfg.API.TLSKeyPath, 95 | StatusPath: cfg.API.Status.Path, 96 | Storage: strg, 97 | } 98 | 99 | httpRcv := &httprcv.Receiver{ 100 | Name: "HTTP receiver", 101 | Host: cfg.HTTPRcv.Host, 102 | Ports: cfg.HTTPRcv.Ports, 103 | TLSPorts: cfg.HTTPRcv.TLS.Ports, 104 | TLSCertPath: cfg.HTTPRcv.TLS.CertPath, 105 | TLSKeyPath: cfg.HTTPRcv.TLS.KeyPath, 106 | IPHeader: cfg.HTTPRcv.IPHeader, 107 | Storage: strg, 108 | } 109 | 110 | txt := cfg.DNSRcv.Txt 111 | if dnsTxt != "" { 112 | txt = append(txt, dnsTxt) 113 | } 114 | dnsRcv := &dnsrcv.Receiver{ 115 | Name: "DNS receiver", 116 | Domain: cfg.DNSRcv.Domain, 117 | Host: cfg.DNSRcv.Host, 118 | Ports: cfg.DNSRcv.Ports, 119 | PublicIP: cfg.DNSRcv.PublicIP, 120 | Txt: txt, 121 | Storage: strg, 122 | } 123 | 124 | errMain := make(chan error, 1) 125 | 126 | go strg.StartExpire(errMain) 127 | go dnsRcv.ListenAndServe(errMain) 128 | 129 | if !dnsOnly { 130 | go apiSrv.ListenAndServe(errMain) 131 | go httpRcv.ListenAndServe(errMain) 132 | } 133 | 134 | if exitErr := <-errMain; exitErr != nil { 135 | log.Info("Fatal error") 136 | log.Debug("Error: %v", exitErr) 137 | os.Exit(1) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "time" 8 | "unicode" 9 | ) 10 | 11 | // Config represents BOAST's configuration. 12 | // It contains the structs for each configuration section and is used to unmarshal the 13 | // TOML configuration file. 14 | type Config struct { 15 | API APIConfig `toml:"api"` 16 | HTTPRcv HTTPRcvConfig `toml:"http_receiver"` 17 | DNSRcv DNSRcvConfig `toml:"dns_receiver"` 18 | Strg StorageConfig `toml:"storage"` 19 | } 20 | 21 | // APIConfig represents the web API configuration. 22 | type APIConfig struct { 23 | Host string `toml:"host"` 24 | Domain string `toml:"domain"` 25 | TLSPort int `toml:"tls_port"` 26 | TLSCertPath string `toml:"tls_cert"` 27 | TLSKeyPath string `toml:"tls_key"` 28 | Status APIStatusConfig `toml:"status"` 29 | } 30 | 31 | // APIStatusConfig represents the web API configuration specific to the status page. 32 | type APIStatusConfig struct { 33 | Path string `toml:"url_path"` 34 | } 35 | 36 | // HTTPRcvConfig represents the HTTP protocol receiver configuration. 37 | type HTTPRcvConfig struct { 38 | Host string `toml:"host"` 39 | Ports []int `toml:"ports"` 40 | TLS HTTPRcvConfigTLS `toml:"tls"` 41 | IPHeader string `toml:"real_ip_header"` 42 | } 43 | 44 | // HTTPRcvConfigTLS represents the HTTP protocol receiver configuration specific to its 45 | // TLS functionalities. 46 | type HTTPRcvConfigTLS struct { 47 | Ports []int `toml:"ports"` 48 | CertPath string `toml:"cert"` 49 | KeyPath string `toml:"key"` 50 | } 51 | 52 | // DNSRcvConfig represents the DNS protocol receiver configuration. 53 | type DNSRcvConfig struct { 54 | Domain string `toml:"domain"` 55 | Host string `toml:"host"` 56 | Ports []int `toml:"ports"` 57 | PublicIP string `toml:"public_ip"` 58 | Txt []string `toml:"txt"` 59 | } 60 | 61 | // StorageConfig represents the storage configuration. 62 | type StorageConfig struct { 63 | MaxEvents int `toml:"max_events"` 64 | MaxEventsByTest int `toml:"max_events_by_test"` 65 | MaxDumpSize byteSize `toml:"max_dump_size"` 66 | HMACKey hmacKey `toml:"hmac_key"` 67 | Expire ExpireConfig `toml:"expire"` 68 | } 69 | 70 | // ExpireConfig represents the storage configurations specific to its expiration feature. 71 | type ExpireConfig struct { 72 | TTL duration `toml:"ttl"` 73 | CheckInterval duration `toml:"check_interval"` 74 | MaxRestarts int `toml:"max_restarts"` 75 | } 76 | 77 | type duration struct { 78 | time.Duration 79 | } 80 | 81 | func (d *duration) UnmarshalText(txt []byte) error { 82 | var err error 83 | d.Duration, err = time.ParseDuration(string(txt)) 84 | return err 85 | } 86 | 87 | func (d *duration) Value() time.Duration { 88 | return d.Duration 89 | } 90 | 91 | type hmacKey []byte 92 | 93 | func (k *hmacKey) UnmarshalText(txt []byte) error { 94 | maxKeySize := 64 95 | if len(txt) > maxKeySize { 96 | return errors.New("hmac_key must be between 0 and 64 bytes long") 97 | } 98 | *k = txt 99 | return nil 100 | } 101 | 102 | // byteSize is the type representing the value in bytes for a given unit string (e.g. "80KB"). 103 | // It's an int since only whole bytes will be considered and it's not realistic that 104 | // somebody will need EiB here (which overflows with int). 105 | type byteSize int 106 | 107 | func (b *byteSize) UnmarshalText(txt []byte) error { 108 | bs, err := parseByteSize(string(txt)) 109 | if err != nil { 110 | return err 111 | } 112 | *b = bs 113 | return nil 114 | } 115 | 116 | func (b *byteSize) Value() int { 117 | return int(*b) 118 | } 119 | 120 | const ( 121 | // B represents 1 byte. 122 | B byteSize = 1 123 | 124 | // KiB is the number of bytes in 1 kibibyte. 125 | KiB = 1 << (10 * iota) 126 | // MiB is the number of bytes in 1 mebibyte. 127 | MiB 128 | // GiB is the number of bytes in 1 gibibyte. 129 | GiB 130 | // TiB is the number of bytes in 1 tebibyte. 131 | TiB 132 | // PiB is the number of bytes in 1 pebibyte. 133 | PiB 134 | // EiB overflows with int (reasons to use int are above byteSize's declaration). 135 | // This means you cannot use your black hole computer's full capacity. 136 | 137 | // KB is the number of bytes in 1 kilobyte. 138 | KB byteSize = 1e3 139 | // MB is the number of bytes in 1 megabyte. 140 | MB byteSize = 1e6 141 | // GB is the number of bytes in 1 gigabyte. 142 | GB byteSize = 1e9 143 | // TB is the number of bytes in 1 terabyte. 144 | TB byteSize = 1e12 145 | // PB is the number of bytes in 1 petabyte. 146 | PB byteSize = 1e15 147 | // EB is the number of bytes in 1 exabyte. 148 | EB byteSize = 1e18 149 | ) 150 | 151 | var unitToByteSize = map[string]byteSize{ 152 | "B": B, 153 | 154 | "KIB": KiB, 155 | "MIB": MiB, 156 | "GIB": GiB, 157 | "TIB": TiB, 158 | "PIB": PiB, 159 | 160 | "KB": KB, 161 | "MB": MB, 162 | "GB": GB, 163 | "TB": TB, 164 | "PB": PB, 165 | "EB": EB, 166 | } 167 | 168 | func parseByteSize(s string) (byteSize, error) { 169 | s = strings.TrimSpace(s) 170 | var ss []string 171 | for i, c := range s { 172 | if !unicode.IsDigit(c) && c != '.' { 173 | ss = append(ss, strings.TrimSpace(string(s[:i]))) 174 | ss = append(ss, strings.TrimSpace(string(s[i:]))) 175 | break 176 | } 177 | } 178 | 179 | if len(ss) != 2 { 180 | return 0, errors.New("wrong format") 181 | } 182 | 183 | unit, exists := unitToByteSize[strings.ToUpper(ss[1])] 184 | if !exists { 185 | return 0, errors.New("unrecognised size suffix " + ss[1]) 186 | } 187 | 188 | sn := ss[0] 189 | var bs byteSize 190 | if strings.Contains(sn, ".") { 191 | n, err := strconv.ParseFloat(sn, 64) 192 | if err != nil { 193 | return 0, err 194 | } 195 | bs = byteSize(n * float64(unit)) 196 | } else { 197 | n, err := strconv.Atoi(sn) 198 | if err != nil { 199 | return 0, err 200 | } 201 | bs = byteSize(n * int(unit)) 202 | } 203 | 204 | return bs, nil 205 | } 206 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "bytes" 5 | "math" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/BurntSushi/toml" 11 | "github.com/ciphermarco/BOAST/config" 12 | ) 13 | 14 | var data = []byte(` 15 | [api] 16 | host = "0.0.0.0" 17 | tls_port = 2096 18 | tls_cert = "/path/to/tls/server.crt" 19 | tls_key = "/path/to/tls/server.key" 20 | 21 | [api.status] 22 | url_path = "rzaedgmqloivvw7v3lamu3tzvi" 23 | 24 | [http_receiver] 25 | host = "0.0.0.0" 26 | ports = [80, 8080] 27 | real_ip_header = "X-Real-IP" 28 | 29 | [http_receiver.tls] 30 | ports = [443, 8443] 31 | cert = "/path/to/tls/server.crt" 32 | key = "/path/to/tls/server.key" 33 | 34 | [dns_receiver] 35 | domain = "example.com" 36 | host = "0.0.0.0" 37 | ports = [53, 5353] 38 | public_ip = "203.0.113.77" 39 | 40 | [storage] 41 | max_events = 1_000_000 42 | max_events_by_test = 100 43 | max_dump_size = "80KB" 44 | hmac_key = "TJkhXnMqSqOaYDiTw7HsfQ==" 45 | 46 | [storage.expire] 47 | ttl = "24h" 48 | check_interval = "1h" 49 | max_restarts = 100 50 | `) 51 | 52 | func TestConfigParse(t *testing.T) { 53 | var cfg config.Config 54 | if err := toml.Unmarshal(data, &cfg); err != nil { 55 | t.Fatalf("config parsing error: %v (want) != %v (got)", 56 | nil, err) 57 | } 58 | 59 | // API 60 | wantAPIHost := "0.0.0.0" 61 | gotAPIHost := cfg.API.Host 62 | if wantAPIHost != gotAPIHost { 63 | t.Errorf("wrong API host: %v (want) != %v (got)", 64 | wantAPIHost, gotAPIHost) 65 | } 66 | 67 | wantAPITLSPort := 2096 68 | gotAPITLSPort := cfg.API.TLSPort 69 | if wantAPITLSPort != gotAPITLSPort { 70 | t.Errorf("wrong API TLS Port: %v (want) != %v (got)", 71 | wantAPITLSPort, gotAPITLSPort) 72 | } 73 | 74 | wantAPITLSCertPath := "/path/to/tls/server.crt" 75 | gotAPITLSCertPath := cfg.API.TLSCertPath 76 | if wantAPITLSCertPath != gotAPITLSCertPath { 77 | t.Errorf("wrong API TLS cert path: %v (want) != %v (got)", 78 | wantAPITLSCertPath, gotAPITLSCertPath) 79 | } 80 | 81 | wantAPITLSKeyPath := "/path/to/tls/server.key" 82 | gotAPITLSKeyPath := cfg.API.TLSKeyPath 83 | if wantAPITLSKeyPath != gotAPITLSKeyPath { 84 | t.Errorf("wrong API TLS key path: %v (want) != %v (got)", 85 | wantAPITLSKeyPath, gotAPITLSKeyPath) 86 | } 87 | 88 | wantAPIStatusPath := "rzaedgmqloivvw7v3lamu3tzvi" 89 | gotAPIStatusPath := cfg.API.Status.Path 90 | if wantAPIStatusPath != gotAPIStatusPath { 91 | t.Errorf("wrong API status URL path: %v (want) != %v (got)", 92 | wantAPIStatusPath, gotAPIStatusPath) 93 | } 94 | 95 | // Storage 96 | wantStrgMaxEvents := 1_000_000 97 | gotStrgMaxEvents := cfg.Strg.MaxEvents 98 | if wantStrgMaxEvents != gotStrgMaxEvents { 99 | t.Errorf("wrong Storage max events: %v (want) != %v (got)", 100 | wantStrgMaxEvents, gotStrgMaxEvents) 101 | } 102 | 103 | wantStrgMaxEventsByTest := 100 104 | gotStrgMaxEventsByTest := cfg.Strg.MaxEventsByTest 105 | if wantStrgMaxEventsByTest != gotStrgMaxEventsByTest { 106 | t.Errorf("wrong Storage max events by test: %v (want) != %v (got)", 107 | wantStrgMaxEventsByTest, gotStrgMaxEventsByTest) 108 | } 109 | 110 | wantStrgMaxDumpSize := int(80 * 1e3) // "80KB" 111 | gotStrgMaxDumpSize := cfg.Strg.MaxDumpSize.Value() 112 | if wantStrgMaxDumpSize != gotStrgMaxDumpSize { 113 | t.Errorf("wrong Storage max dump size: %v (want) != %v (got)", 114 | wantStrgMaxDumpSize, gotStrgMaxDumpSize) 115 | } 116 | 117 | wantStrgHMACKey := []byte("TJkhXnMqSqOaYDiTw7HsfQ==") 118 | gotStrgHMACKey := cfg.Strg.HMACKey 119 | if !bytes.Equal(wantStrgHMACKey, gotStrgHMACKey) { 120 | t.Errorf("wrong Storage HMAC key: %v (want) != %v (got)", 121 | wantStrgHMACKey, gotStrgHMACKey) 122 | } 123 | 124 | wantStrgTTL := time.Duration(24 * time.Hour) 125 | gotStrgTTL := cfg.Strg.Expire.TTL.Value() 126 | if wantStrgTTL != gotStrgTTL { 127 | t.Errorf("wrong Storage TTL: %v (want) != %v (got)", 128 | wantStrgTTL, gotStrgTTL) 129 | } 130 | 131 | wantStrgCheckInterval := time.Duration(1 * time.Hour) 132 | gotStrgCheckInterval := cfg.Strg.Expire.CheckInterval.Value() 133 | if wantStrgCheckInterval != gotStrgCheckInterval { 134 | t.Errorf("wrong Storage check interval: %v (want) != %v (got)", 135 | wantStrgCheckInterval, gotStrgCheckInterval) 136 | } 137 | 138 | wantStrgMaxRestarts := 100 139 | gotStrgMaxRestarts := cfg.Strg.Expire.MaxRestarts 140 | if wantStrgMaxRestarts != gotStrgMaxRestarts { 141 | t.Errorf("wrong Storage max restarts: %v (want) != %v (got)", 142 | wantStrgMaxRestarts, gotStrgMaxRestarts) 143 | } 144 | 145 | // HTTP Receiver 146 | wantHTTPRcvHost := "0.0.0.0" 147 | gotHTTPRcvHost := cfg.HTTPRcv.Host 148 | if wantHTTPRcvHost != gotHTTPRcvHost { 149 | t.Errorf("wrong HTTP receiver host: %v (want) != %v (got)", 150 | wantHTTPRcvHost, gotHTTPRcvHost) 151 | } 152 | 153 | wantHTTPRcvPorts := []int{80, 8080} 154 | gotHTTPRcvPorts := cfg.HTTPRcv.Ports 155 | if !reflect.DeepEqual(wantHTTPRcvPorts, gotHTTPRcvPorts) { 156 | t.Errorf("wrong HTTP receiver ports: %v (want) != %v (got)", 157 | wantHTTPRcvPorts, gotHTTPRcvPorts) 158 | } 159 | 160 | wantHTTPRcvIPHeader := "X-Real-IP" 161 | gotHTTPRcvIPHeader := cfg.HTTPRcv.IPHeader 162 | if wantHTTPRcvIPHeader != gotHTTPRcvIPHeader { 163 | t.Errorf("wrong HTTP receiver IP header: %v (want) != %v (got)", 164 | wantHTTPRcvIPHeader, gotHTTPRcvIPHeader) 165 | } 166 | 167 | wantHTTPRcvTLSPorts := []int{443, 8443} 168 | gotHTTPRcvTLSPorts := cfg.HTTPRcv.TLS.Ports 169 | if !reflect.DeepEqual(wantHTTPRcvTLSPorts, gotHTTPRcvTLSPorts) { 170 | t.Errorf("wrong HTTP receiver TLS ports: %v (want) != %v (got)", 171 | wantHTTPRcvTLSPorts, gotHTTPRcvTLSPorts) 172 | } 173 | 174 | wantHTTPRcvTLSCertPath := "/path/to/tls/server.crt" 175 | gotHTTPRcvTLSCertPath := cfg.HTTPRcv.TLS.CertPath 176 | if wantHTTPRcvTLSCertPath != gotHTTPRcvTLSCertPath { 177 | t.Errorf("wrong HTTP receiver TLS cert path: %v (want) != %v (got)", 178 | wantHTTPRcvTLSCertPath, gotHTTPRcvTLSCertPath) 179 | } 180 | 181 | wantHTTPRcvTLSKeyPath := "/path/to/tls/server.key" 182 | gotHTTPRcvTLSKeyPath := cfg.HTTPRcv.TLS.KeyPath 183 | if wantHTTPRcvTLSKeyPath != gotHTTPRcvTLSKeyPath { 184 | t.Errorf("wrong HTTP receiver TLS key path: %v (want) != %v (got)", 185 | wantHTTPRcvTLSKeyPath, gotHTTPRcvTLSKeyPath) 186 | } 187 | 188 | // DNS Receiver 189 | wantDNSRcvDomain := "example.com" 190 | gotDNSRcvDomain := cfg.DNSRcv.Domain 191 | if wantDNSRcvDomain != gotDNSRcvDomain { 192 | t.Errorf("wrong DNS receiver domain: %v (want) != %v (got)", 193 | wantDNSRcvDomain, gotDNSRcvDomain) 194 | } 195 | 196 | wantDNSRcvHost := "0.0.0.0" 197 | gotDNSRcvHost := cfg.DNSRcv.Host 198 | if wantDNSRcvHost != gotDNSRcvHost { 199 | t.Errorf("wrong DNS receiver host: %v (want) != %v (got)", 200 | wantDNSRcvHost, gotDNSRcvHost) 201 | } 202 | 203 | wantDNSRcvPorts := []int{53, 5353} 204 | gotDNSRcvPorts := cfg.DNSRcv.Ports 205 | if !reflect.DeepEqual(wantDNSRcvPorts, gotDNSRcvPorts) { 206 | t.Errorf("wrong DNS receiver ports: %v (want) != %v (got)", 207 | wantDNSRcvPorts, gotDNSRcvPorts) 208 | } 209 | 210 | wantDNSRcvPublicIP := "203.0.113.77" 211 | gotDNSRcvPublicIP := cfg.DNSRcv.PublicIP 212 | if wantDNSRcvPublicIP != gotDNSRcvPublicIP { 213 | t.Errorf("wrong DNS receiver public IP: %v (want) != %v (got)", 214 | wantDNSRcvPublicIP, gotDNSRcvPublicIP) 215 | } 216 | } 217 | 218 | func TestStorageMaxDumpFloatRounding(t *testing.T) { 219 | var maxDumpSize = []byte( 220 | `[storage] 221 | # an invalid float syntax will make ParseFloat fail so the error 222 | # code path can be reached 223 | max_dump_size = "80.5555555KB"`, 224 | ) 225 | var cfg config.Config 226 | if err := toml.Unmarshal(maxDumpSize, &cfg); err != nil { 227 | t.Fatalf("unexpected error: %v (want) != %v (got)", 228 | nil, err) 229 | } 230 | 231 | wantStrgMaxDumpSize := 80555 // from 80555.5555 232 | gotStrgMaxDumpSize := cfg.Strg.MaxDumpSize.Value() 233 | if wantStrgMaxDumpSize != gotStrgMaxDumpSize { 234 | t.Errorf("wrong max_dump_size: %v (want) != %v (got)", 235 | wantStrgMaxDumpSize, gotStrgMaxDumpSize) 236 | } 237 | } 238 | 239 | func TestStorageHMACKeyParseError(t *testing.T) { 240 | var tooLongHMACKey = []byte( 241 | `[storage] 242 | hmac_key = "UtFdm4qQa56yZEfwWEWf1NG/IJKzUya6jYtWCWKqjAclUaiEI5hXh9LrBfJrWEkmM/dXnvxiDgfHeD+EjkRCEpY="`, 243 | ) 244 | var cfg config.Config 245 | if err := toml.Unmarshal(tooLongHMACKey, &cfg); err == nil { 246 | t.Errorf("parsing hmac_key did not fail: error (want) != %v (got)", 247 | err) 248 | } 249 | } 250 | 251 | func TestStorageMaxDumpSizeFormatError(t *testing.T) { 252 | var badMaxDumpSize = []byte( 253 | `[storage] 254 | max_dump_size = "80"`, 255 | ) 256 | var cfg config.Config 257 | if err := toml.Unmarshal(badMaxDumpSize, &cfg); err == nil { 258 | t.Errorf("parsing max_dump_size did not fail: error (want) != %v (got)", 259 | err) 260 | } 261 | } 262 | 263 | func TestStorageMaxDumpSizeSuffixError(t *testing.T) { 264 | var badMaxDumpSize = []byte( 265 | `[storage] 266 | max_dump_size = "80KBB"`, 267 | ) 268 | var cfg config.Config 269 | if err := toml.Unmarshal(badMaxDumpSize, &cfg); err == nil { 270 | t.Errorf("parsing max_dump_size did not fail: error (want) != %v (got)", 271 | err) 272 | } 273 | } 274 | 275 | func TestStorageMaxDumpParseFloatError(t *testing.T) { 276 | var badMaxDumpSize = []byte( 277 | `[storage] 278 | # an invalid float syntax will make ParseFloat fail so the error 279 | # code path can be reached 280 | max_dump_size = "1.0000000000000001110223024625156540423631668090820312500...001KB"`, 281 | ) 282 | var cfg config.Config 283 | if err := toml.Unmarshal(badMaxDumpSize, &cfg); err == nil { 284 | t.Errorf("parsing max_dump_size did not fail: error (want) != %v (got)", 285 | err) 286 | } 287 | } 288 | 289 | func TestStorageMaxDumpSizeAtoiError(t *testing.T) { 290 | var badMaxDumpSize = []byte( 291 | `[storage] 292 | # a too big int will make Atoi fail so the error code path 293 | # can be reached 294 | max_dump_size = "99999999999999999999999999999999999999KB"`, 295 | ) 296 | var cfg config.Config 297 | if err := toml.Unmarshal(badMaxDumpSize, &cfg); err == nil { 298 | t.Errorf("parsing max_dump_size did not fail: error (want) != %v (got)", 299 | err) 300 | } 301 | } 302 | 303 | func TestParseByteSize(t *testing.T) { 304 | wantB := 1 305 | gotB, err := config.ParseByteSize("1B") 306 | if err != nil { 307 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 308 | } 309 | if wantB != int(gotB) { 310 | t.Errorf("wrong B byte size: %v (want) != %v (got)", 311 | wantB, gotB) 312 | } 313 | 314 | wantKiB := 1024 315 | gotKiB, err := config.ParseByteSize("1KiB") 316 | if err != nil { 317 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 318 | } 319 | if wantKiB != int(gotKiB) { 320 | t.Errorf("wrong KiB byte size: %v (want) != %v (got)", 321 | wantKiB, gotKiB) 322 | } 323 | 324 | wantMiB := int(math.Pow(1024, 2)) 325 | gotMiB, err := config.ParseByteSize("1MiB") 326 | if err != nil { 327 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 328 | } 329 | if wantMiB != int(gotMiB) { 330 | t.Errorf("wrong MiB byte size: %v (want) != %v (got)", 331 | wantMiB, gotMiB) 332 | } 333 | 334 | wantGiB := int(math.Pow(1024, 3)) 335 | gotGiB, err := config.ParseByteSize("1GiB") 336 | if err != nil { 337 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 338 | } 339 | if wantGiB != int(gotGiB) { 340 | t.Errorf("wrong GiB byte size: %v (want) != %v (got)", 341 | wantGiB, gotGiB) 342 | } 343 | 344 | wantTiB := int(math.Pow(1024, 4)) 345 | gotTiB, err := config.ParseByteSize("1TiB") 346 | if err != nil { 347 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 348 | } 349 | if wantTiB != int(gotTiB) { 350 | t.Errorf("wrong TiB byte size: %v (want) != %v (got)", 351 | wantTiB, gotTiB) 352 | } 353 | 354 | wantPiB := int(math.Pow(1024, 5)) 355 | gotPiB, err := config.ParseByteSize("1PiB") 356 | if err != nil { 357 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 358 | } 359 | if wantPiB != int(gotPiB) { 360 | t.Errorf("wrong PiB byte size: %v (want) != %v (got)", 361 | wantPiB, gotPiB) 362 | } 363 | 364 | wantKB := 1000 365 | gotKB, err := config.ParseByteSize("1KB") 366 | if err != nil { 367 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 368 | } 369 | if wantKB != int(gotKB) { 370 | t.Errorf("wrong KB byte size: %v (want) != %v (got)", 371 | wantKB, gotKB) 372 | } 373 | 374 | wantMB := int(math.Pow(1000, 2)) 375 | gotMB, err := config.ParseByteSize("1MB") 376 | if err != nil { 377 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 378 | } 379 | if wantMB != int(gotMB) { 380 | t.Errorf("wrong MB byte size: %v (want) != %v (got)", 381 | wantMB, gotMB) 382 | } 383 | 384 | wantGB := int(math.Pow(1000, 3)) 385 | gotGB, err := config.ParseByteSize("1GB") 386 | if err != nil { 387 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 388 | } 389 | if wantGB != int(gotGB) { 390 | t.Errorf("wrong GB byte size: %v (want) != %v (got)", 391 | wantGB, gotGB) 392 | } 393 | 394 | wantTB := int(math.Pow(1000, 4)) 395 | gotTB, err := config.ParseByteSize("1TB") 396 | if err != nil { 397 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 398 | } 399 | if wantTB != int(gotTB) { 400 | t.Errorf("wrong TB byte size: %v (want) != %v (got)", 401 | wantTB, gotTB) 402 | } 403 | 404 | wantPB := int(math.Pow(1000, 5)) 405 | gotPB, err := config.ParseByteSize("1PB") 406 | if err != nil { 407 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 408 | } 409 | if wantPB != int(gotPB) { 410 | t.Errorf("wrong PB byte size: %v (want) != %v (got)", 411 | wantPB, gotPB) 412 | } 413 | 414 | wantEB := int(math.Pow(1000, 6)) 415 | gotEB, err := config.ParseByteSize("1EB") 416 | if err != nil { 417 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 418 | } 419 | if wantEB != int(gotEB) { 420 | t.Errorf("wrong EB byte size: %v (want) != %v (got)", 421 | wantEB, gotEB) 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /config/export_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ParseByteSize = parseByteSize 4 | -------------------------------------------------------------------------------- /docs/boast-configuration.md: -------------------------------------------------------------------------------- 1 | # BOAST configuration 2 | 3 | ## Flags 4 | 5 | * `-config` | _(string)_ | TOML configuration file (default "boast.toml") 6 | * `-dns_only` | Run only the DNS receiver and its dependencies 7 | * `-dns_txt` | DNS receiver's TXT record 8 | * `-log_file` | _(string)_ | Path to log file 9 | * `-log_level` | _(int)_ | Set the logging level (0=DEBUG|1=INFO) (default 1) 10 | * `-v` | Print program version and quit 11 | 12 | ## Configuration file 13 | 14 | By default, BOAST will look for a file called `boast.toml` in the working directory but this behaviour can be changed with the `-config` flag. 15 | Example configuration files may be found in [the config directory](https://github.com/ciphermarco/boast/tree/master/examples/config). 16 | 17 | Here's a brief description of each configuration section and its parameters: 18 | 19 | ### Temporary storage 20 | 21 | The `[storage]` section is required as the server is useless without it. 22 | 23 | Only the `hmac_key` parameter is optional, but you should be aware of the implications. (TODO: explain the implications :]) 24 | 25 | * `[storage]`: Section for the temporary in-memory events storage. 26 | * `max_events` _(int)_ | The maximum number of events to be held by the server in a given moment | Example value: `1_000_000` 27 | * `max_events_by_test` _(int)_ | The maximum number of events by test | Example value: `"80KB"` 28 | * `hmac_key` _(string)_ | The HMAC key to be used by the server's HMAC algorithm | Example value: `"TJkhXnMqSqOaYDiTw7HsfQ=="` 29 | * `[storage.expire]`: Section for the storage's expiration feature. 30 | * `ttl` _(string)_ | Time to live for the stored events | Example value: `"24h"` 31 | * `check_interval` _(string)_ | Interval for checking and deleting expired events according to `ttl` | Example value: `"1h"` 32 | * `max_restarts` _(int)_ | Maximum attempts to restart the expiration routine before crashing | Example value: `100` 33 | 34 | ### API 35 | 36 | The `[api]` section is required as the server is useless without it. 37 | 38 | The `domain` parameter is optional and can be used for setting a different domain for 39 | the API if needed (e.g. the API is behind a proxy for protection). If `domain` is not set, the API will be respond in any subdomain (e.g. anything.example.com). 40 | 41 | The `[api.status]` subsection is optional and it will just deactivate the status page if not set. 42 | 43 | * `[api]`: Section for the web API. 44 | * `domain` _(string)_ | The domain name for the API | Example value: `"proxied.example.com"` 45 | * `host` _(string)_ | The host for the API | Example value: `"0.0.0.0"` 46 | * `tls_port` _(int)_ | The TLS port for the API | Example value: `2096` 47 | * `tls_cert` _(string)_ | The TLS certificate file for the API | Example value: `"/path/to/tls/fullchain.pem"` 48 | * `tls_key` _(string)_ | The TLS private key file for the API | Example value: `"/path/to/tls/privkey.pem"` 49 | * `[api.status]`: section for the server's status page 50 | * `url_path` _(string)_ | The secret URL path for the satus page | Example value: `"rzaedgmqloivvw7v3lamu3tzvi"` 51 | 52 | ### HTTP receiver 53 | 54 | The `[http_receiver]` is optional. 55 | 56 | The `host` parameter is required if any of the `ports` parameters is set. 57 | 58 | The `[http_receiver.tls]`'s `cert` and `key` are required if any TLS `ports` is set. 59 | 60 | `real_ip_header` is always optional. 61 | 62 | * `[http_receiver]`: Section for the HTTP protocol receiver. 63 | * `host` _(string)_ | The host for the HTTP receiver | Example value: `"0.0.0.0"` 64 | * `ports` _([]int)_ | The ports for the HTTP receiver | Example value: `[80, 8080]` 65 | * `real_ip_header` _(string)_ | The client's real IP header to be recorded when proxied | Example: `"X-Real-IP"` 66 | * `[http_receiver.tls]`: Section for the HTTP receiver's TLS configuration. 67 | * `ports` _([]int)_ | The TLS ports for the HTTP protocol receiver | Example value: `[443, 8443]` 68 | * `cert` _(string)_ | The TLS certificate file for the HTTP protocol receiver | Example value: `"/path/to/tls/fullchain.pem"` 69 | * `key` _(string)_ | The TLS private key file for the HTTP protocol receiver | Example value: `"/path/to/tls/privkey.pem"` 70 | 71 | ### DNS receiver 72 | 73 | The `[dns_receiver]` is optional. 74 | 75 | If the `ports` parameter is set, the only optional parameter is the `txt`. All the other 76 | parameters are required for the correct functioning. 77 | 78 | * `[dns_receiver]`: Section for the DNS protocol receiver. 79 | * `host` _(string)_ | The host for the DNS receiver | Example value: `"0.0.0.0"` 80 | * `ports` _([]int)_ | The ports for the DNS receiver | Example value: `[53]` 81 | * `domain` _(string)_ | The domain name for the server | Example value: `"example.com"` 82 | * `public_ip` _(string)_ | The server's publicly accessible IP | Example value: `"203.0.113.77` 83 | * `txt` _([]string)_ | An arbitrary TXT DNS record | Example value: `["testing", "TXT"]` 84 | -------------------------------------------------------------------------------- /docs/boast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciphermarco/BOAST/ad93c04cfb6e456230b2e9e3fd66064cf19876e0/docs/boast.png -------------------------------------------------------------------------------- /docs/deploying.md: -------------------------------------------------------------------------------- 1 | # Deploying 2 | 3 | ## Makefile 4 | 5 | The Makefile can be found 6 | [here](https://github.com/ciphermarco/boast/blob/master/Makefile) and, together with the 7 | Go module files, should make it very easy to build BOAST by yourself. You most likely 8 | want to issue `make` for building and `make test` for running the package's tests. 9 | 10 | ## BOAST configuration 11 | 12 | The server's configuration file is described on 13 | [boast-configuration.md](https://github.com/ciphermarco/boast/blob/master/docs/boast-configuration.md) 14 | and example configurations can be found in the [config examples 15 | directory](https://github.com/ciphermarco/boast/tree/master/examples/config). 16 | 17 | ## Log level 18 | 19 | The default log level is INFO (1) which must not disclose any details about the 20 | reactions events. The log level can be changed to DEBUG (0) passing the `-log_level=0` 21 | flag to the binary. I may implement this flag with their mnemonics instead of numbers 22 | soon to make it more obvious, but this will always be a flag and never a parameter in 23 | the configuration file or any other somewhat implicit way. The reason for this is that 24 | avoiding the mistake of unintentionally logging possibly sensitive testing information 25 | is paramount. 26 | 27 | ## Deploying with Docker 28 | 29 | A Dockerfile, a BOAST configuration file (`boast.toml`), and `certbot` pre validation 30 | and renew hooks can all be found [in the build 31 | directory](https://github.com/ciphermarco/boast/tree/master/build). They are meant to 32 | work together and you must edit some parameters in the `boast.toml` file. Additionally, 33 | you may need to edit other files if you want a different setup. 34 | 35 | Also note that the steps listed below may be followed with a variety of divergences 36 | depending on your on preferences that will not be exhaustively detailed on this part of 37 | the document since this aims to be a simple tutorial. In general, this only means that 38 | some pieces like the exact DNS records may be configured in slightly different ways and 39 | still be valid (or not even be used at all for less functionality), but the overall 40 | process should remain very similar for any case. 41 | 42 | This tutorial assumes you have cloned the repository and is at the project's root 43 | directory. 44 | 45 | ### 1. DNS configuration 46 | 47 | For full functionality, BOAST runs its own DNS server to respond and record queries 48 | about the domain used for the protocol receivers. Thus, you have to dedicate an internal 49 | or external domain or subdomain for this use. If your domain is `example.com`, you DNS 50 | configuration should look something like this: 51 | 52 | ``` 53 | example.com. IN NS ns1.example.com. 54 | example.com. IN NS ns2.example.com. 55 | ``` 56 | 57 | You also need to configure the glue-records for the NS domains. As these depend on how 58 | your domain registrar exposes them on their interface, you should search their 59 | documentation or contact support for more details. 60 | 61 | ### 2. Edit `boast.toml` 62 | 63 | If you change any of the uncommented parameters, you may have to change one or more 64 | parts of the remaining steps. For now, you only have to be careful about the ports as 65 | this will change the port parameters for the `docker run` command. 66 | 67 | Using the `boast.toml`, these are the values you need to uncomment and possibly change: 68 | 69 | * `storage` section: `max_events`, `max_events_by_test`, `max_dump_size`, `hmac_key`. 70 | * `storage.expire` subsection: `ttl`, `check_interval`, `max_restarts`. 71 | * `dns_receiver` section: `domain`, `public_ip`. 72 | 73 | The other commented parameters are optional and may be changed at will. 74 | 75 | For more details, have a look [at the configuration 76 | section](https://github.com/ciphermarco/boast/blob/master/docs/boast-configuration.md). 77 | 78 | ### 3. Build the docker image and run BOAST with the `-dns_only` flag 79 | 80 | ``` 81 | $ docker build . -t boastimg -f build/Dockerfile 82 | $ docker run -d --name boastdns -p 53:53/udp boastimg /go/src/github.com/ciphermarco/BOAST/boast -dns_only 83 | ``` 84 | 85 | This will build the BOAST's Docker image and run it in a container named `boastdns` with 86 | the option flag `-dns_only`. As this option will only be used for the ACME DNS-01 87 | challenge, there's no need to put this in the Dockerfile directly. 88 | 89 | Having the DNS server running before the Let's Encrypt ACME DNS-01 challenge is 90 | necessary or else it fails. The `-dns_only` flag is used so only the DNS receiver and 91 | its dependencies are run and you don't have to worry about the TLS files not being in 92 | place yet or anything else. Make sure the DNS receiver is configured to at least listen 93 | on port 53 as this will be used for the ACME challenge as expected. 94 | 95 | ### 4. Wildcard TLS certificate 96 | 97 | As BOAST will freely and dynamically use subdomains for its operations, it needs an 98 | wildcard TLS certificate for the configured domain. You need to perform some variation 99 | of this step even if you choose to not use the HTTPS receiver (by not configuring its 100 | TLS ports) as the API only supports HTTPS. Of course, the certificate can be self-signed 101 | or acquired by other means. The only requirement is that the TLS files must be PEM 102 | encoded as [documented here](https://golang.org/pkg/crypto/tls/#LoadX509KeyPair). 103 | 104 | To perform a Let's Encrypt ACME DNS-01 challenge to acquire a wildcard certificate, you 105 | need [`certbot`](https://github.com/certbot/certbot) and a little help from BOAST to 106 | respond to the challenge. Assuming the domain is `example.com` and the hook script has 107 | execute permission, you may use this command: 108 | 109 | ``` 110 | $ certbot certonly --agree-tos --manual --preferred-challenges=dns -d *.example.com -d example.com --manual-auth-hook ./build/certbot-dns-01-pre-hook.sh 111 | ``` 112 | 113 | This command will attempt a wildcard certificate issuance from Let's Encrypt using the 114 | provided script as a pre-validation hook. 115 | 116 | An ACME DNS-01 challenge will be initiated and the validation string will be available 117 | to the pre-validation hook script as `$CERTBOT_VALIDATION`. Any container named 118 | `boastdns` or `boast` will be stopped and a new `-dns_only` BOAST container will be run 119 | using the flag `-dns_txt` with the validation string as value. As the DNS receiver 120 | responds the same TXT record for any subdomain, this will make sure that Let's Encrypt 121 | will find the validation TXT record for the `_acme_challenge` subdomain with a record 122 | similar to this: 123 | 124 | ``` 125 | _acme-challenge.example.com. 300 IN TXT "SIbmWivQZsP9RyoEb4KFjNYPywSU-YsDUlPtWc1xDWg" 126 | ``` 127 | 128 | If everything worked correctly and the validation was successful, the script output will 129 | let you know with a congratulations message and information about your certificate files. 130 | 131 | Now, copy the certificate files to a directory for this use so it can be reliably used 132 | to mount a volume inside the container without the other files or problems with symlinks: 133 | 134 | ``` 135 | $ cp /etc/letsencrypt/live/example.com/fullchain.pem ./tls 136 | $ cp /etc/letsencrypt/live/example.com/privkey.pem ./tls 137 | ``` 138 | 139 | ### 5. Run 140 | 141 | The only thing you need to do now is run a container with the exposed ports and a volume 142 | containing the TLS files at the right container's path: 143 | 144 | ``` 145 | $ docker run -d --name boastmain -p 53:53/udp -p 80:80 -p 443:443 -p 2096:2096 -p 8080:8080 -p 8443:8443 \ 146 | -v $PWD/tls:/go/src/github.com/ciphermarco/BOAST/tls boastimg 147 | ``` 148 | 149 | And you can [start using it](https://github.com/ciphermarco/boast/blob/master/docs/interacting.md). 150 | 151 | ### 6. Automate the certificate renewal 152 | 153 | This part of the documentation will be improved for better reproducibility, but, for 154 | now, the renew hook script may need some editing to work on your end. Make sure to test 155 | it before delegating it to a `certbot` cron job. 156 | 157 | For automating the certificate renewal process, you can use the `cerbot` pre validation 158 | and renew hooks found in [the build 159 | directory](https://github.com/ciphermarco/boast/tree/master/build). You just have to put 160 | them in the right hook directories to be run by `certbot` when renewing or by using the 161 | flags `--manual-auth-hook` and `--renew-hook` to run the hooks non-interactively like 162 | this: 163 | 164 | ``` 165 | certbot certonly -n --agree-tos --manual-public-ip-logging-ok --manual --preferred-challenges=dns -d *.example.com \ 166 | --manual-auth-hook $HOME/boast/build/certbot-dns-01-pre-validation-hook.sh \ 167 | --renew-hook $HOME/boast/build/certbot-dns-01-renew-hook.sh 168 | ``` 169 | 170 | Using the hook directories is recommended, but, when using the flags, make sure the 171 | paths to the hooks are right. 172 | 173 | In both cases, you just have to run the `certbot` command from a cron job with your 174 | preferences and these two hooks. The hooks will only be run if a renewal is due so, with 175 | attention to [Let's Encrypt's rate limits](https://letsencrypt.org/docs/rate-limits/), 176 | it's safe to run the cron job routinely for automatic certification renewal and server 177 | restart with the new certificate. 178 | 179 | To make customization easier, here's the minimum operations the pre validation and renew 180 | hooks or alternatives should do: 181 | 182 | **Pre validation hook:** 183 | 184 | 1. Stop any conflicting BOAST containers or restart it without binding it to port 53. 185 | 186 | 2. Start a DNS-only BOAST container with the right validation TXT record. 187 | 188 | **Renew hook:** 189 | 190 | 1. Stop any conflicting BOAST containers. 191 | 192 | 2. Start the main BOAST container with the new certificates accessible to BOAST. 193 | 194 | ### Using a different domain for the API 195 | 196 | One possibility not yet covered by this document is to configure the API's `domain` 197 | parameter in the [configuration 198 | file](https://github.com/ciphermarco/boast/edit/master/docs/boast-configuration.md). 199 | Doing this will allow you to protect the API with a proxy or what else may need a domain 200 | not dedicated to BOAST's DNS receiver. It will not be possible to perform the ACME 201 | DNS-01 challenge using the BOAST's DNS receiver as a helper and you'll need to configure 202 | the API's TLS file paths, but you may issue a self-signed certificate for the API only 203 | (hence without the ACME challenge) if that fits your requiremets. 204 | 205 | ### Possible improvements 206 | 207 | 1. This can be made more automated and reproducible by pushing the whole ACME DNS-01 208 | challenge validation to Docker with `certbot` (or alternative) included. This way, 209 | the challenge container can be orchestated to perform the whole challenge process 210 | without host dependencies and save certificates to a volume to be shared with the 211 | main BOAST container. But I'm yet to document it :). 212 | 213 | 2. Automate most of the process with an installation script. 214 | -------------------------------------------------------------------------------- /docs/interacting.md: -------------------------------------------------------------------------------- 1 | # Interacting 2 | 3 | Interacting with the server is quite simple. There's a small example bash client to show 4 | how simple it is on 5 | [../examples/bash_client/client.sh](https://github.com/ciphermarco/boast/blob/master/examples/bash_client/client.sh). 6 | Don't mind its potential lack of elegance though; it was only made to show how little 7 | preparation you need to interact with the server. 8 | 9 | All you have to do is send a GET request containing an `Authorization` header with the 10 | value `Secret ` to the HTTPS API's `/events` endpoint. And that's 11 | it. You'll receive a test `id` which can be used as an unique domain for your payloads 12 | (e.g. `.example.com`). And when you wish to retrieve new possibly existing events, 13 | you just need to do the same to check if the `events` array was updated. 14 | 15 | Of course, the experience is intended to be better with a client such as 16 | [ZAP](https://github.com/zaproxy/zaproxy/issues/3022) (when/if it comes to be supported) 17 | or at least a script better than the example bash client. 18 | 19 | Now let's see a terminal interaction example step-by-step so it's clear. This 20 | walkthrough will assume a server controlled by a third-party where you don't have 21 | control over the events' TTL and other parameters. 22 | 23 | ## Registration 24 | 25 | ### 1. Generate a base64 random secret 26 | 27 | You can generate it as you wish. The only limitations are that it must be a valid base64 28 | and not longer than 44 bytes decoded. 29 | 30 | ``` 31 | $ openssl rand -base64 32 32 | kkMrhv3ic2Em63PH6duIejNVRiqyOYpfBZHkjTDswBk= 33 | ``` 34 | 35 | ### 2. Register 36 | 37 | ``` 38 | $ curl -H "Authorization: Secret kkMrhv3ic2Em63PH6duIejNVRiqyOYpfBZHkjTDswBk=" https://example.com:2096/events 39 | {"id":"cxcjyaf5wahkidrp2zvhxe6ola","canary":"x7ilthx62hx2kfyvsioydd43da","events":[]} 40 | ``` 41 | 42 | This will give you a test `id` that can be used on your tests and a `canary` token that 43 | can be used by protocol receivers when responding to the target application to aid some 44 | kinds of test results detection. The most useful form to use it is to send 45 | `cxcjyaf5wahkidrp2zvhxe6ola.example.com` in your payloads. There are other reasons for 46 | this, but a good one is that, by using this unique domain in your payloads, even if the 47 | target application is behind some firewall or whatnot and other protocol communications 48 | fail (e.g. an HTTP request is blocked), you could be lucky (not uncommon) and receive 49 | the DNS query for this unique domain in case the outbound restrictions do not apply to 50 | the DNS protocol and port. 51 | 52 | Both the `id` and `canary` are deterministically generated from the sent secret using a 53 | cryptographic hash function. This means that if the server maintains the `hmac_key` 54 | configuration parameter, you can expect to always receive the same `id` and `canary` for 55 | a given secret. This is nice to have because, even if the server is restarted, your 56 | previously sent payloads will still be valid and, after repeating this step 2, the 57 | server will be able to recognise any late reactions from the target application. This is 58 | one of the most important reasons for why these values are deterministically generated. 59 | 60 | Note that, depending on the server's configuration, the API domain could be different 61 | from the protocol receivers' domain. 62 | 63 | ### 3. Generate an event (and optionally check it's populating `events`) 64 | 65 | ``` 66 | $ curl http://example.com/cxcjyaf5wahkidrp2zvhxe6ola 67 | x7ilthx62hx2kfyvsioydd43da 68 | ``` 69 | 70 | This steps does more than just to check if the server is working. It is advisable to do 71 | this periodically because tests without any events will be deleted according to the 72 | server's 73 | [configured](https://github.com/ciphermarco/boast/blob/master/docs/boast-configuration.md) 74 | `checkInterval` which may be too soon for your test reactions to happen and be recorded 75 | by the server. This is a limitation you don't have to worry if you control the server as 76 | you can change the configuration parameters to best suit your needs, but it is important 77 | to have this in mind in the case of using a third-party server. 78 | 79 | ## Retrieving events 80 | 81 | For retrieving events, you only need to repeat step 2 from the last section. And if some 82 | new event has been generated and not expired yet, the `events` array will be populated 83 | with it. 84 | 85 | ``` 86 | % curl -k -H "Authorization: Secret kkMrhv3ic2Em63PH6duIejNVRiqyOYpfBZHkjTDswBk=" https://example.com:2096/events 87 | {"id":"cxcjyaf5wahkidrp2zvhxe6ola","canary":"x7ilthx62hx2kfyvsioydd43da","events":[{"id":"fbb6osymic6llzuiw7f7ylwix4","time":"2020-09-16T16:31:05.183124969+01:00","testID":"cxcjyaf5wahkidrp2zvhxe6ola","receiver":"HTTP","remoteAddress":"127.0.0.1:57770","dump":"GET /cxcjyaf5wahkidrp2zvhxe6ola HTTP/1.1\r\nHost: localhost:8080\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: en-GB,en;q=0.5\r\nConnection: keep-alive\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:79.0) Gecko/20100101 Firefox/79.0\r\n\r\n"}]} 88 | ``` 89 | -------------------------------------------------------------------------------- /examples/bash_client/client.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This is a very simple client example to show how to interact with the server. If you 3 | # run the program without any args (or with an unexpected one), it will just return your 4 | # ID that serves as your unique token for tests and your unique domain for convenience. 5 | # The script requires jq to be installed. 6 | 7 | ### 8 | # These values are for documentation only. Change them! 9 | # _host="example.com" # host running BOAST 10 | _host="localhost" 11 | _port="2096" # the server's API port 12 | _b64secret="872k5eD/lGRbMZ3GqIPB0bUzqRjBlt1lhLH4+/42sKa=" # your secret (44 bytes max.) 13 | # _b64secret could be generated with: `$ openssl rand -base64 32` 14 | ### 15 | 16 | function usage { 17 | cat << EOF 18 | Usage: $0 19 | * can be set to http, https, dns, or all. 20 | EOF 21 | exit 1 22 | } 23 | 24 | if [ "$1" == "-h" ]; then 25 | usage; 26 | fi 27 | 28 | # GET /events with the "Authorization" header containing a Base64 secret. 29 | # * Authorization header format: "Authorization: Secret " 30 | # * Base64 secret's maximum size: 44 bytes 31 | _header="Authorization: Secret ${_b64secret}" 32 | _events=$(curl --silent -X GET https://$_host:$_port/events -H "${_header}") 33 | 34 | if [ "$1" == "http" ] || [ "$1" == "https" ] || [ "$1" == "dns" ]; then 35 | echo $_events | jq ".events[] | select(.receiver==\"${1^^}\")" 36 | elif [ "$1" == "all" ]; then 37 | echo $_events | jq 38 | else 39 | _id=$(echo $_events | jq -r .id) 40 | echo "Your id is $_id" 41 | echo "Your unique domain is: $_id.$_host" 42 | fi 43 | -------------------------------------------------------------------------------- /examples/config/development-example.toml: -------------------------------------------------------------------------------- 1 | # BOAST's development configuration EXAMPLE. 2 | # These values are usually good enough for local development and testing, 3 | # but make sure the configured ports are available and the paths to TLS files 4 | # are right. 5 | # 6 | [storage] 7 | max_events = 1_000_000 8 | max_events_by_test = 100 9 | max_dump_size = "80KB" 10 | hmac_key = "testing" 11 | 12 | [storage.expire] 13 | ttl = "24h" 14 | check_interval = "1h" 15 | max_restarts = 100 16 | 17 | [api] 18 | host = "127.0.0.1" 19 | # domain = "localhost" 20 | tls_port = 2096 21 | tls_cert = "./.tlstest/fullchain.pem" 22 | tls_key = "./.tlstest/privkey.pem" 23 | 24 | [api.status] 25 | url_path = "mvqdz5spzlrfrjhafyxsfwx66u" 26 | 27 | [http_receiver] 28 | host = "127.0.0.1" 29 | ports = [8080] 30 | # real_ip_header = "X-Real-IP" 31 | 32 | [http_receiver.tls] 33 | ports = [8443] 34 | cert = "./.tlstest/testserver.crt" 35 | key = "./.tlstest/testserver.key" 36 | 37 | [dns_receiver] 38 | host = "127.0.0.1" 39 | ports = [8053] 40 | domain = "localhost" 41 | public_ip = "127.0.0.1" 42 | -------------------------------------------------------------------------------- /examples/config/production-example.toml: -------------------------------------------------------------------------------- 1 | # BOAST's production configuration EXAMPLE. 2 | # DO NOT USE AS IT IS. Verify each value carefully and change them accordingly. 3 | # 4 | [storage] 5 | max_events = 1_000_000 6 | max_events_by_test = 100 7 | max_dump_size = "80KB" 8 | # DO NOT USE THIS hmac_key. Generate your own. 9 | hmac_key = "TJkhXnMqSqOaYDiTw7HsfQ==" 10 | 11 | [storage.expire] 12 | ttl = "24h" 13 | check_interval = "1h" 14 | max_restarts = 100 15 | 16 | [api] 17 | domain = "example.com" 18 | host = "0.0.0.0" 19 | tls_port = 2096 20 | tls_cert = "/path/to/tls/fullchain.pem" 21 | tls_key = "/path/to/tls/privkey.pem" 22 | 23 | [api.status] 24 | # DO NOT USE THIS url_path. Generate your own. 25 | url_path = "rzaedgmqloivvw7v3lamu3tzvi" 26 | 27 | [http_receiver] 28 | host = "0.0.0.0" 29 | ports = [80, 8080] 30 | # Fill real_ip_header in with the correct field name if proxied. This way, 31 | # receivers can record the right client's address. Otherwise, the last node's 32 | # address will be recorded. This value is case-insensitive as expected. 33 | # real_ip_header = "X-Real-IP" 34 | 35 | [http_receiver.tls] 36 | ports = [443, 8443] 37 | cert = "/path/to/tls/server.crt" 38 | key = "/path/to/tls/server.key" 39 | 40 | [dns_receiver] 41 | host = "0.0.0.0" 42 | ports = [53] 43 | domain = "example.com" 44 | public_ip = "203.0.113.77" 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ciphermarco/BOAST 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/coredns/coredns v1.11.3 8 | github.com/go-chi/chi/v5 v5.1.0 9 | github.com/go-chi/render v1.0.3 10 | github.com/miekg/dns v1.1.61 11 | github.com/prometheus/procfs v0.15.1 12 | golang.org/x/crypto v0.25.0 13 | golang.org/x/net v0.27.0 14 | ) 15 | 16 | require ( 17 | github.com/ajg/form v1.5.1 // indirect 18 | github.com/golang/protobuf v1.5.4 // indirect 19 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 21 | github.com/prometheus/client_model v0.6.1 // indirect 22 | github.com/prometheus/common v0.55.0 // indirect 23 | golang.org/x/mod v0.19.0 // indirect 24 | golang.org/x/sync v0.7.0 // indirect 25 | golang.org/x/sys v0.22.0 // indirect 26 | golang.org/x/text v0.16.0 // indirect 27 | golang.org/x/tools v0.23.0 // indirect 28 | google.golang.org/protobuf v1.34.2 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 4 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 5 | github.com/coredns/coredns v1.11.3 h1:8RjnpZc42db5th84/QJKH2i137ecJdzZK1HJwhetSPk= 6 | github.com/coredns/coredns v1.11.3/go.mod h1:lqFkDsHjEUdY7LJ75Nib3lwqJGip6ewWOqNIf8OavIQ= 7 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 8 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 9 | github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 10 | github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 11 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 12 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 13 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 14 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 15 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 16 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 17 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 18 | github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= 19 | github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 21 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 22 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 23 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 24 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 25 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 26 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 27 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 28 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 29 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 30 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 31 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 32 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 33 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 34 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 36 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 37 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 38 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 39 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 40 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 41 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 42 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 43 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 44 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 45 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | stdLog "log" 7 | "os" 8 | "syscall" 9 | "time" 10 | ) 11 | 12 | type level int 13 | 14 | const ( 15 | debug level = iota 16 | info 17 | ) 18 | 19 | var ( 20 | // Logger represents a custom logging object. 21 | // It's exported so it can be used by api.httplogger until it's changed. 22 | Logger = stdLog.New(&logWriter{out: os.Stdout}, "", stdLog.Lshortfile) 23 | curLevel = info 24 | labels = map[level]string{ 25 | debug: "DEBUG", 26 | info: "INFO", 27 | } 28 | ) 29 | 30 | type logWriter struct { 31 | out io.Writer 32 | } 33 | 34 | func (w logWriter) Write(b []byte) (int, error) { 35 | tid := fmt.Sprintf(" %d ", syscall.Gettid()) 36 | return fmt.Fprint(w.out, time.Now().UTC().Format("2006-01-02T15:04:05.999Z")+tid+string(b)) 37 | } 38 | 39 | func log(lvl level, format string, v ...interface{}) { 40 | if lvl < curLevel || curLevel > info || curLevel < debug { 41 | return 42 | } 43 | format = fmt.Sprintf("[%s] %s", labels[lvl], format) 44 | Logger.Output(3, fmt.Sprintf(format, v...)) 45 | } 46 | 47 | // SetLevel sets the logging level. 48 | func SetLevel(lvl int) { 49 | curLevel = level(lvl) 50 | } 51 | 52 | // SetOutput sets the output to a new io.Writer object. 53 | func SetOutput(w io.Writer) { 54 | Logger.SetOutput(&logWriter{out: w}) 55 | } 56 | 57 | // Info logs an INFO logging line. 58 | func Info(format string, v ...interface{}) { 59 | log(info, format, v...) 60 | } 61 | 62 | // Debug logs a DEBUG logging line. 63 | func Debug(format string, v ...interface{}) { 64 | log(debug, format, v...) 65 | } 66 | 67 | // Printf calls logger.Output to print to the logger without any labels. 68 | // Arguments are handled in the manner of fmt.Printf. 69 | func Printf(format string, v ...interface{}) { 70 | Logger.Output(2, fmt.Sprintf(format, v...)) 71 | } 72 | 73 | // Print calls logger.Output to print to the logger without any labels. 74 | // Arguments are handled in the manner of fmt.Print. 75 | func Print(v ...interface{}) { 76 | Logger.Output(2, fmt.Sprint(v...)) 77 | } 78 | 79 | // Println calls logger.Output to print to the logger without any labels. 80 | // Arguments are handled in the manner of fmt.Println. 81 | func Println(v ...interface{}) { 82 | Logger.Output(2, fmt.Sprintln(v...)) 83 | } 84 | 85 | // Fatalf is equivalent to Printf() followed by a call to os.Exit(1). 86 | func Fatalf(format string, v ...interface{}) { 87 | Logger.Output(2, fmt.Sprintf(format, v...)) 88 | os.Exit(1) 89 | } 90 | 91 | // Fatalln is equivalent to Println() followed by a call to os.Exit(1). 92 | func Fatalln(v ...interface{}) { 93 | Logger.Output(2, fmt.Sprintln(v...)) 94 | os.Exit(1) 95 | } 96 | -------------------------------------------------------------------------------- /log/log_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/ciphermarco/BOAST/log" 8 | ) 9 | 10 | func TestSetLevelWorks(t *testing.T) { 11 | var buf bytes.Buffer 12 | 13 | log.SetOutput(&buf) 14 | log.SetLevel(0) // debug 15 | log.Debug("testing Debug logs") 16 | 17 | got := buf.String() 18 | if got == "" { 19 | t.Errorf("empty log line: (want) != \"%v\" (got)", 20 | got) 21 | } 22 | 23 | buf.Reset() 24 | log.Info("testing Info logs") 25 | 26 | got = buf.String() 27 | if got == "" { 28 | t.Errorf("empty log line: (want) != \"%v\" (got)", 29 | got) 30 | } 31 | 32 | buf.Reset() 33 | log.SetLevel(1) // info 34 | log.Debug("testing debug does not log") 35 | 36 | got = buf.String() 37 | if got != "" { 38 | t.Errorf("wrong log line: (want) != \"%v\" (got)", 39 | got) 40 | } 41 | 42 | buf.Reset() 43 | log.Info("testing info still logs") 44 | 45 | got = buf.String() 46 | if got == "" { 47 | t.Errorf("empty log line: (want) != \"%v\" (got)", 48 | got) 49 | } 50 | } 51 | 52 | func TestPrintfLogs(t *testing.T) { 53 | var buf bytes.Buffer 54 | 55 | log.SetOutput(&buf) 56 | log.SetLevel(0) // debug 57 | log.Printf("testing Printf logs") 58 | 59 | got := buf.String() 60 | if got == "" { 61 | t.Errorf("empty log line: (want) != \"%v\" (got)", 62 | got) 63 | } 64 | 65 | buf.Reset() 66 | log.SetLevel(1) // info 67 | log.Printf("testing Printf still logs") 68 | 69 | got = buf.String() 70 | if got == "" { 71 | t.Errorf("empty log line: (want) != \"%v\" (got)", 72 | got) 73 | } 74 | } 75 | 76 | func TestPrintLogs(t *testing.T) { 77 | var buf bytes.Buffer 78 | 79 | log.SetOutput(&buf) 80 | log.SetLevel(0) // debug 81 | log.Print("testing Printf logs") 82 | 83 | got := buf.String() 84 | if got == "" { 85 | t.Errorf("empty log line: (want) != \"%v\" (got)", 86 | got) 87 | } 88 | 89 | buf.Reset() 90 | log.SetLevel(1) // info 91 | log.Print("testing Printf still logs") 92 | 93 | got = buf.String() 94 | if got == "" { 95 | t.Errorf("empty log line: (want) != \"%v\" (got)", 96 | got) 97 | } 98 | } 99 | 100 | func TestPrintlnLogs(t *testing.T) { 101 | var buf bytes.Buffer 102 | 103 | log.SetOutput(&buf) 104 | log.SetLevel(0) // debug 105 | log.Println("testing Println logs") 106 | 107 | got := buf.String() 108 | if got == "" { 109 | t.Errorf("empty log line: (want) != \"%v\" (got)", 110 | got) 111 | } 112 | 113 | buf.Reset() 114 | log.SetLevel(1) // info 115 | log.Println("testing Println still logs") 116 | 117 | got = buf.String() 118 | if got == "" { 119 | t.Errorf("empty log line: (want) != \"%v\" (got)", 120 | got) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /receivers/dnsrcv/dnsrcv.go: -------------------------------------------------------------------------------- 1 | package dnsrcv 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | 8 | app "github.com/ciphermarco/BOAST" 9 | "github.com/ciphermarco/BOAST/log" 10 | 11 | "github.com/miekg/dns" 12 | ) 13 | 14 | const shortTTL = 300 15 | 16 | // Receiver represents the DNS protocol receiver. 17 | type Receiver struct { 18 | Name string 19 | Domain string 20 | Host string 21 | Ports []int 22 | PublicIP string 23 | Txt []string 24 | Storage app.Storage 25 | } 26 | 27 | // ListenAndServe sets the necessary conditions for the underlying dns.Server 28 | // to serve the BOAST's custom DNS server for each configured port. 29 | // 30 | // For full functionality, this server must be used as nameserver for the domain. 31 | // 32 | // Any errors are returned via the received channel. 33 | func (r *Receiver) ListenAndServe(err chan error) { 34 | for _, port := range r.Ports { 35 | go func(p int) { 36 | addr := r.Host + fmt.Sprintf(":%d", p) 37 | srv := &dns.Server{ 38 | Addr: addr, 39 | Net: "udp", 40 | } 41 | 42 | srv.Handler = &dnsHandler{ 43 | domain: r.Domain, 44 | publicIP: r.PublicIP, 45 | txt: r.Txt, 46 | storage: r.Storage, 47 | } 48 | 49 | log.Info("%s: Listening on %s\n", r.Name, addr) 50 | err <- srv.ListenAndServe() 51 | }(port) 52 | } 53 | } 54 | 55 | type dnsHandler struct { 56 | domain string 57 | publicIP string 58 | txt []string 59 | storage app.Storage 60 | } 61 | 62 | var queryTypeNames = map[uint16]string{ 63 | dns.TypeA: "A", 64 | dns.TypeNS: "NS", 65 | dns.TypeSOA: "SOA", 66 | dns.TypeMX: "MX", 67 | dns.TypeCNAME: "CNAME", 68 | dns.TypeAAAA: "AAAA", 69 | dns.TypeTXT: "TXT", 70 | } 71 | 72 | // ServeDNS is the handler for BOAST's DNS queries. 73 | // It responds to A, NS, SOA, and MX queries always pointing to the same IP. 74 | func (d *dnsHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { 75 | log.Info("DNS event received") 76 | msg := dns.Msg{} 77 | msg.SetReply(r) 78 | 79 | id, canary := d.storage.SearchTest( 80 | func(key, value string) bool { 81 | return strings.Contains(msg.Question[0].Name, key) 82 | }, 83 | ) 84 | 85 | if id != "" { 86 | qTypeName := queryTypeNames[r.Question[0].Qtype] 87 | evt, err := app.NewDNSEvent( 88 | id, 89 | "DNS", 90 | w.RemoteAddr().String(), 91 | r.String(), 92 | qTypeName, 93 | ) 94 | if err != nil { 95 | log.Info("Error creating a new DNS event") 96 | log.Debug("New DNS event error: %v", err) 97 | } else { 98 | if err := d.storage.StoreEvent(evt); err != nil { 99 | log.Info("Error storing a new DNS event") 100 | log.Debug("Store DNS event error: %v", err) 101 | } else { 102 | log.Info("New DNS event stored") 103 | } 104 | log.Debug("DNS event object:\n%s", evt.String()) 105 | } 106 | } else { 107 | log.Debug("DNS event test not found: id=\"%s\" canary=\"%s\"", 108 | id, canary) 109 | } 110 | 111 | d.setDNSAnswer(&msg, r) 112 | w.WriteMsg(&msg) 113 | } 114 | 115 | func (d *dnsHandler) setDNSAnswer(msg, r *dns.Msg) { 116 | qName := msg.Question[0].Name 117 | if strings.HasSuffix(toFQDN(qName), toFQDN(d.domain)) { 118 | msg.Authoritative = true 119 | hdr := dns.RR_Header{ 120 | Name: qName, 121 | Class: dns.ClassINET, 122 | Ttl: shortTTL, 123 | } 124 | 125 | qType := r.Question[0].Qtype 126 | 127 | if qType == dns.TypeA || qType == dns.TypeANY { 128 | hdr.Rrtype = dns.TypeA 129 | msg.Answer = append(msg.Answer, 130 | &dns.A{ 131 | Hdr: hdr, 132 | A: net.ParseIP(d.publicIP), 133 | }) 134 | } 135 | 136 | if qType == dns.TypeNS || qType == dns.TypeANY { 137 | hdr.Rrtype = dns.TypeNS 138 | msg.Answer = append(msg.Answer, &dns.NS{ 139 | Hdr: hdr, 140 | Ns: "ns1." + toFQDN(d.domain), 141 | }) 142 | msg.Answer = append(msg.Answer, &dns.NS{ 143 | Hdr: hdr, 144 | Ns: "ns2." + toFQDN(d.domain), 145 | }) 146 | } 147 | 148 | if qType == dns.TypeSOA || qType == dns.TypeANY { 149 | hdr.Rrtype = dns.TypeSOA 150 | msg.Answer = append(msg.Answer, &dns.SOA{ 151 | Hdr: hdr, 152 | Ns: "ns1." + toFQDN(d.domain), 153 | Mbox: "mail." + toFQDN(d.domain), 154 | Refresh: 604800, 155 | Serial: 10000, 156 | Retry: 11000, 157 | Expire: 120000, 158 | Minttl: 10000, 159 | }) 160 | } 161 | 162 | if qType == dns.TypeMX || qType == dns.TypeANY { 163 | hdr.Rrtype = dns.TypeMX 164 | msg.Answer = append(msg.Answer, &dns.MX{ 165 | Hdr: hdr, 166 | Preference: 1, 167 | Mx: "mail." + toFQDN(d.domain), 168 | }) 169 | } 170 | 171 | if len(d.txt) > 0 { 172 | if qType == dns.TypeTXT || qType == dns.TypeANY { 173 | hdr.Rrtype = dns.TypeTXT 174 | msg.Answer = append(msg.Answer, &dns.TXT{ 175 | Hdr: hdr, 176 | Txt: d.txt, 177 | }) 178 | } 179 | } 180 | } 181 | } 182 | 183 | func toFQDN(s string) string { 184 | l := len(s) 185 | if l == 0 || s[l-1] == '.' { 186 | return strings.ToLower(s) 187 | } 188 | return strings.ToLower(s) + "." 189 | } 190 | -------------------------------------------------------------------------------- /receivers/dnsrcv/dnsrcv_test.go: -------------------------------------------------------------------------------- 1 | package dnsrcv_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/ciphermarco/BOAST/log" 10 | "github.com/ciphermarco/BOAST/receivers/dnsrcv" 11 | 12 | "github.com/coredns/coredns/plugin/pkg/dnstest" 13 | "github.com/coredns/coredns/plugin/test" 14 | "github.com/miekg/dns" 15 | ) 16 | 17 | func TestMain(m *testing.M) { 18 | log.SetOutput(ioutil.Discard) 19 | os.Exit(m.Run()) 20 | } 21 | 22 | var exampleDomain = "example.com." 23 | var exampleIP = "203.0.113.77" 24 | 25 | func NewTestHandler() *dnsrcv.ExportDNSHandler { 26 | return dnsrcv.NewExportDNSHandler(exampleDomain, exampleIP, []string{"testing"}, &mockStorage{}) 27 | } 28 | 29 | func TestDNSResponseA(t *testing.T) { 30 | handler := NewTestHandler() 31 | 32 | for _, n := range []string{"example.com.", "sub.example.com."} { 33 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 34 | dnsMsg := &dns.Msg{} 35 | dnsMsg.SetQuestion(n, dns.TypeA) 36 | 37 | handler.ServeDNS(qr, dnsMsg) 38 | 39 | if qr.Msg == nil { 40 | t.Fatal("got nil message") 41 | } 42 | 43 | if qr.Msg.Rcode == dns.RcodeNameError { 44 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 45 | } 46 | 47 | a, ok := qr.Msg.Answer[0].(*dns.A) 48 | if !ok { 49 | t.Fatal("wrong type") 50 | } 51 | 52 | if a.A.String() != exampleIP { 53 | t.Errorf("wrong A: %v (want) != %v (got)", exampleIP, a.A) 54 | } 55 | } 56 | } 57 | 58 | func TestDNSResponseNS(t *testing.T) { 59 | handler := NewTestHandler() 60 | 61 | for _, n := range []string{"example.com.", "sub.example.com."} { 62 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 63 | dnsMsg := &dns.Msg{} 64 | dnsMsg.SetQuestion(n, dns.TypeNS) 65 | 66 | handler.ServeDNS(qr, dnsMsg) 67 | 68 | if qr.Msg == nil { 69 | t.Fatal("got nil message") 70 | } 71 | 72 | if qr.Msg.Rcode == dns.RcodeNameError { 73 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 74 | } 75 | 76 | ns, ok := qr.Msg.Answer[0].(*dns.NS) 77 | if !ok { 78 | t.Fatal("wrong type") 79 | } 80 | 81 | want := "ns1." + exampleDomain 82 | if ns.Ns != want { 83 | t.Errorf("wrong Ns: %v (want) != %v (got)", want, ns.Ns) 84 | } 85 | 86 | ns2, ok := qr.Msg.Answer[1].(*dns.NS) 87 | if !ok { 88 | t.Fatal("wrong type") 89 | } 90 | 91 | want2 := "ns2." + exampleDomain 92 | if ns.Ns != want { 93 | t.Errorf("wrong Ns: %v (want) != %v (got)", want2, ns2.Ns) 94 | } 95 | } 96 | } 97 | 98 | func TestDNSResponseSOA(t *testing.T) { 99 | handler := NewTestHandler() 100 | 101 | for _, n := range []string{"example.com.", "sub.example.com."} { 102 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 103 | dnsMsg := &dns.Msg{} 104 | dnsMsg.SetQuestion(n, dns.TypeSOA) 105 | 106 | handler.ServeDNS(qr, dnsMsg) 107 | 108 | if qr.Msg == nil { 109 | t.Fatal("got nil message") 110 | } 111 | 112 | if qr.Msg.Rcode == dns.RcodeNameError { 113 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 114 | } 115 | 116 | soa, ok := qr.Msg.Answer[0].(*dns.SOA) 117 | if !ok { 118 | t.Fatal("wrong type") 119 | } 120 | 121 | wantNs := "ns1." + exampleDomain 122 | if soa.Ns != wantNs { 123 | t.Errorf("wrong Ns: %v (want) != %v (got)", wantNs, soa.Ns) 124 | } 125 | 126 | wantMbox := "mail." + exampleDomain 127 | if soa.Mbox != wantMbox { 128 | t.Errorf("wrong Mbox: %v (want) != %v (got)", wantMbox, soa.Mbox) 129 | } 130 | } 131 | } 132 | 133 | func TestDNSResponseMX(t *testing.T) { 134 | handler := NewTestHandler() 135 | 136 | for _, n := range []string{"example.com.", "sub.example.com."} { 137 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 138 | dnsMsg := &dns.Msg{} 139 | dnsMsg.SetQuestion(n, dns.TypeMX) 140 | 141 | handler.ServeDNS(qr, dnsMsg) 142 | 143 | if qr.Msg == nil { 144 | t.Fatal("got nil message") 145 | } 146 | 147 | if qr.Msg.Rcode == dns.RcodeNameError { 148 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 149 | } 150 | 151 | mx, ok := qr.Msg.Answer[0].(*dns.MX) 152 | if !ok { 153 | t.Fatal("wrong type") 154 | } 155 | 156 | wantMx := "mail." + exampleDomain 157 | if mx.Mx != wantMx { 158 | t.Errorf("wrong Mx: %v (want) != %v (got)", wantMx, mx.Mx) 159 | } 160 | } 161 | } 162 | 163 | func TestDNSResponseTXT(t *testing.T) { 164 | handler := NewTestHandler() 165 | 166 | for _, n := range []string{"example.com.", "sub.example.com."} { 167 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 168 | dnsMsg := &dns.Msg{} 169 | dnsMsg.SetQuestion(n, dns.TypeTXT) 170 | 171 | handler.ServeDNS(qr, dnsMsg) 172 | 173 | if qr.Msg == nil { 174 | t.Fatal("got nil message") 175 | } 176 | 177 | if qr.Msg.Rcode == dns.RcodeNameError { 178 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 179 | } 180 | 181 | txt, ok := qr.Msg.Answer[0].(*dns.TXT) 182 | if !ok { 183 | t.Fatal("wrong type") 184 | } 185 | 186 | wantTxt := []string{"testing"} 187 | if !reflect.DeepEqual(txt.Txt, wantTxt) { 188 | t.Errorf("wrong Txt: %v (want) != %v (got)", wantTxt, txt.Txt) 189 | } 190 | } 191 | } 192 | 193 | func TestDNSResponseANY(t *testing.T) { 194 | handler := NewTestHandler() 195 | 196 | for _, n := range []string{"example.com.", "sub.example.com."} { 197 | qr := dnstest.NewRecorder(&test.ResponseWriter{}) 198 | dnsMsg := &dns.Msg{} 199 | dnsMsg.SetQuestion(n, dns.TypeANY) 200 | 201 | handler.ServeDNS(qr, dnsMsg) 202 | 203 | if qr.Msg == nil { 204 | t.Fatal("got nil message") 205 | } 206 | 207 | if qr.Msg.Rcode == dns.RcodeNameError { 208 | t.Errorf("expected NOERROR got %s", dns.RcodeToString[qr.Msg.Rcode]) 209 | } 210 | 211 | for _, rr := range qr.Msg.Answer { 212 | switch rr.(type) { 213 | case *dns.A: 214 | a := rr.(*dns.A) 215 | if a.A.String() != exampleIP { 216 | t.Errorf("wrong A: %v (want) != %v (got)", exampleIP, a.A) 217 | } 218 | case *dns.NS: 219 | ns := rr.(*dns.NS) 220 | want := "ns1." + exampleDomain 221 | want2 := "ns2." + exampleDomain 222 | if ns.Ns != want && ns.Ns != want2 { 223 | t.Errorf("wrong Ns: %v (want) != %v (got)", want, ns.Ns) 224 | } 225 | case *dns.SOA: 226 | soa := rr.(*dns.SOA) 227 | wantNs := "ns1." + exampleDomain 228 | if soa.Ns != wantNs { 229 | t.Errorf("wrong Ns: %v (want) != %v (got)", wantNs, soa.Ns) 230 | } 231 | 232 | wantMbox := "mail." + exampleDomain 233 | if soa.Mbox != wantMbox { 234 | t.Errorf("wrong Mbox: %v (want) != %v (got)", wantMbox, soa.Mbox) 235 | } 236 | case *dns.MX: 237 | mx := rr.(*dns.MX) 238 | wantMx := "mail." + exampleDomain 239 | if mx.Mx != wantMx { 240 | t.Errorf("wrong Mx: %v (want) != %v (got)", wantMx, mx.Mx) 241 | } 242 | case *dns.TXT: 243 | txt := rr.(*dns.TXT) 244 | wantTxt := []string{"testing"} 245 | if !reflect.DeepEqual(txt.Txt, wantTxt) { 246 | t.Errorf("wrong Txt: %v (want) != %v (got)", wantTxt, txt.Txt) 247 | } 248 | } 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /receivers/dnsrcv/export_test.go: -------------------------------------------------------------------------------- 1 | package dnsrcv 2 | 3 | import app "github.com/ciphermarco/BOAST" 4 | 5 | type ExportDNSHandler struct { 6 | dnsHandler 7 | } 8 | 9 | func NewExportDNSHandler(domain string, publicIP string, txt []string, strg app.Storage) *ExportDNSHandler { 10 | return &ExportDNSHandler{ 11 | dnsHandler{ 12 | domain: domain, 13 | publicIP: publicIP, 14 | txt: txt, 15 | storage: strg, 16 | }, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /receivers/dnsrcv/mock_test.go: -------------------------------------------------------------------------------- 1 | package dnsrcv_test 2 | 3 | import app "github.com/ciphermarco/BOAST" 4 | 5 | type mockStorage struct{} 6 | 7 | var tID = "mpqhomfbxab55m5de32mywvfoy" 8 | var tCanary = "k2b27meg7dfifvxuxmnfnm24oa" 9 | 10 | func (s *mockStorage) SetTest(secret []byte) (id string, canary string, err error) { 11 | return "", "", nil 12 | } 13 | 14 | func (s *mockStorage) LoadEvents(id string) (evts []app.Event, loaded bool) { 15 | return evts, false 16 | } 17 | 18 | func (s *mockStorage) SearchTest(f func(k, v string) bool) (id string, canary string) { 19 | return tID, tCanary 20 | } 21 | 22 | func (s *mockStorage) StoreEvent(evt app.Event) error { 23 | return nil 24 | } 25 | 26 | func (s *mockStorage) TotalTests() int { 27 | return 0 28 | } 29 | 30 | func (s *mockStorage) TotalEvents() int { 31 | return 0 32 | } 33 | 34 | func (s *mockStorage) StartExpire(err chan error) {} 35 | -------------------------------------------------------------------------------- /receivers/httprcv/export_test.go: -------------------------------------------------------------------------------- 1 | package httprcv 2 | 3 | var CatchAll = catchAll 4 | -------------------------------------------------------------------------------- /receivers/httprcv/httprcv.go: -------------------------------------------------------------------------------- 1 | package httprcv 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "net/http/httputil" 8 | "strings" 9 | "time" 10 | 11 | app "github.com/ciphermarco/BOAST" 12 | "github.com/ciphermarco/BOAST/log" 13 | ) 14 | 15 | // Receiver represents the HTTP protocol receiver. 16 | type Receiver struct { 17 | Name string 18 | Host string 19 | Ports []int 20 | TLSPorts []int 21 | TLSCertPath string 22 | TLSKeyPath string 23 | IPHeader string 24 | Storage app.Storage 25 | } 26 | 27 | // ListenAndServe sets the necessary conditions for the underlying http.Server 28 | // to serve the HTTP and/or the HTTPS server for each configured port. 29 | // 30 | // Any errors are returned via the received channel. 31 | func (r *Receiver) ListenAndServe(err chan error) { 32 | http.HandleFunc("/", catchAll(r.Storage, r.IPHeader)) 33 | 34 | for _, port := range r.Ports { 35 | go func(p int) { 36 | r.serveHTTP(p, err) 37 | }(port) 38 | } 39 | 40 | for _, port := range r.TLSPorts { 41 | go func(p int) { 42 | r.serveHTTPS(p, err) 43 | }(port) 44 | } 45 | } 46 | 47 | func (r *Receiver) serveHTTP(port int, err chan error) { 48 | addr := r.Addr(port) 49 | srv := &http.Server{ 50 | Addr: addr, 51 | ReadTimeout: 5 * time.Second, 52 | WriteTimeout: 10 * time.Second, 53 | IdleTimeout: 120 * time.Second, 54 | } 55 | 56 | log.Info("%s: Listening on http://%s\n", r.Name, addr) 57 | err <- srv.ListenAndServe() 58 | } 59 | 60 | func (r *Receiver) serveHTTPS(port int, err chan error) { 61 | tlsConfig := &tls.Config{ 62 | PreferServerCipherSuites: true, 63 | CurvePreferences: []tls.CurveID{ 64 | tls.CurveP256, tls.X25519, 65 | }, 66 | } 67 | 68 | addr := r.Addr(port) 69 | srv := &http.Server{ 70 | Addr: addr, 71 | TLSConfig: tlsConfig, 72 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 73 | ReadTimeout: 5 * time.Second, 74 | WriteTimeout: 10 * time.Second, 75 | IdleTimeout: 120 * time.Second, 76 | } 77 | 78 | log.Info("%s: Listening on https://%s\n", r.Name, addr) 79 | err <- srv.ListenAndServeTLS(r.TLSCertPath, r.TLSKeyPath) 80 | } 81 | 82 | // Addr returns an address in the format expected by http.Server. 83 | func (r *Receiver) Addr(port int) string { 84 | return r.Host + fmt.Sprintf(":%d", port) 85 | } 86 | 87 | func catchAll(strg app.Storage, ipHdr string) func(w http.ResponseWriter, r *http.Request) { 88 | return func(w http.ResponseWriter, r *http.Request) { 89 | log.Info("HTTP event received") 90 | dump, err := httputil.DumpRequest(r, true) 91 | if err != nil { 92 | log.Info("Could not dump HTTP request event") 93 | log.Debug("Dump HTTP request error: %v", err) 94 | errCode := http.StatusInternalServerError 95 | errTxt := http.StatusText(errCode) 96 | http.Error(w, errTxt, errCode) 97 | return 98 | } 99 | 100 | // Does the request contain any known test ID (id)? 101 | searchDumpID := func(k, v string) bool { 102 | return strings.Contains(string(dump), k) 103 | } 104 | id, canary := strg.SearchTest(searchDumpID) 105 | if id == "" || canary == "" { 106 | log.Debug("HTTP event test not found: id=\"%s\" canary=\"%s\"", 107 | id, canary) 108 | u := "https://github.com/ciphermarco/BOAST" 109 | h := fmt.Sprintf("BOAST (learn more)", u) 110 | fmt.Fprint(w, h) 111 | return 112 | } 113 | 114 | // HTTP or HTTPS event? 115 | rcv := "HTTP" 116 | if r.TLS != nil { 117 | rcv = "HTTPS" 118 | } 119 | 120 | // Real IP header? 121 | remoteAddr := r.RemoteAddr 122 | if realIP := r.Header.Get(ipHdr); realIP != "" { 123 | remoteAddr = realIP 124 | } 125 | 126 | // Try to create and store the event 127 | evt, err := app.NewEvent(id, rcv, remoteAddr, string(dump)) 128 | if err != nil { 129 | log.Info("Error creating a new HTTP event") 130 | log.Debug("New HTTP event error: %v", err) 131 | } else { 132 | if err := strg.StoreEvent(evt); err != nil { 133 | log.Info("Error storing a new HTTP event") 134 | log.Debug("Store HTTP event error: %v", err) 135 | } else { 136 | log.Info("New HTTP event stored") 137 | } 138 | log.Debug("HTTP event object:\n%s", evt.String()) 139 | } 140 | 141 | // Respond the canary to the client 142 | fmt.Fprintf(w, "%s", canary) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /receivers/httprcv/httprcv_test.go: -------------------------------------------------------------------------------- 1 | package httprcv_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | "github.com/ciphermarco/BOAST/log" 12 | "github.com/ciphermarco/BOAST/receivers/httprcv" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | log.SetOutput(ioutil.Discard) 17 | os.Exit(m.Run()) 18 | } 19 | 20 | func TestExpectedCanary(t *testing.T) { 21 | req, err := http.NewRequest("GET", "/mpqhomfbxab55m5de32mywvfoy", nil) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | mockStrg := &mockStorage{} 27 | rr := httptest.NewRecorder() 28 | handler := http.HandlerFunc(httprcv.CatchAll(mockStrg, "")) 29 | handler.ServeHTTP(rr, req) 30 | 31 | checkStatusCode(http.StatusOK, rr.Code, t) 32 | 33 | want := fmt.Sprintf("%s", tCanary) 34 | got := rr.Body.String() 35 | 36 | if want != got { 37 | t.Errorf("canary not found: %v (want) != %v (got)", want, got) 38 | } 39 | } 40 | 41 | func checkStatusCode(want int, got int, t *testing.T) { 42 | if want != got { 43 | t.Errorf("handler returned wrong status code: %v (want) != %v (got)", 44 | want, got) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /receivers/httprcv/mock_test.go: -------------------------------------------------------------------------------- 1 | package httprcv_test 2 | 3 | import app "github.com/ciphermarco/BOAST" 4 | 5 | var tID = "mpqhomfbxab55m5de32mywvfoy" 6 | var tCanary = "k2b27meg7dfifvxuxmnfnm24oa" 7 | 8 | type mockStorage struct{} 9 | 10 | func (s *mockStorage) SetTest(secret []byte) (id string, canary string, err error) { 11 | return tID, tCanary, nil 12 | } 13 | 14 | func (s *mockStorage) LoadEvents(id string) (evts []app.Event, loaded bool) { 15 | return evts, false 16 | } 17 | 18 | func (s *mockStorage) SearchTest(f func(k, v string) bool) (id string, canary string) { 19 | return tID, tCanary 20 | } 21 | 22 | func (s *mockStorage) StoreEvent(evt app.Event) error { 23 | return nil 24 | } 25 | 26 | func (s *mockStorage) TotalTests() int { 27 | return 0 28 | } 29 | 30 | func (s *mockStorage) TotalEvents() int { 31 | return 0 32 | } 33 | 34 | func (s *mockStorage) StartExpire(err chan error) {} 35 | -------------------------------------------------------------------------------- /storage/export_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/base64" 5 | "hash" 6 | "math/rand" 7 | "time" 8 | 9 | app "github.com/ciphermarco/BOAST" 10 | "github.com/ciphermarco/BOAST/log" 11 | 12 | "golang.org/x/crypto/blake2b" 13 | ) 14 | 15 | type ExportTest struct { 16 | test 17 | Secret []byte 18 | } 19 | 20 | var tTestSecret, _ = base64.StdEncoding.DecodeString( 21 | "872k5eD/lGRbMZ3GqIPB0bUzqRjBlt1lhLH4+/42sKa=") 22 | 23 | var TTest = &ExportTest{ 24 | test: test{ 25 | id: "mpqhomfbxab55m5de32mywvfoy", 26 | canary: "k2b27meg7dfifvxuxmnfnm24oa", 27 | events: &eventHeap{}, 28 | }, 29 | Secret: tTestSecret, 30 | } 31 | 32 | func (u *ExportTest) ID() string { 33 | return u.id 34 | } 35 | 36 | func (u *ExportTest) Canary() string { 37 | return u.canary 38 | } 39 | 40 | type ExportStorage struct { 41 | *Storage 42 | } 43 | 44 | func (s *ExportStorage) TotalEvents() int { 45 | return s.Storage.TotalEvents() 46 | } 47 | 48 | func (s *ExportStorage) MaxTests() int { 49 | return s.maxTests 50 | } 51 | 52 | func (s *ExportStorage) MaxEvents() int { 53 | return s.cfg.MaxEvents 54 | } 55 | 56 | func (s *ExportStorage) MaxEventsByTest() int { 57 | return s.cfg.MaxEventsByTest 58 | } 59 | 60 | func (s *ExportStorage) TTL() time.Duration { 61 | return s.cfg.TTL 62 | } 63 | 64 | func (s *ExportStorage) CheckInterval() time.Duration { 65 | return s.cfg.CheckInterval 66 | } 67 | 68 | func NewTestConfig() *Config { 69 | return &Config{ 70 | TTL: 100 * time.Minute, 71 | CheckInterval: 1 * time.Second, 72 | MaxRestarts: 10, 73 | MaxEvents: 1000, 74 | MaxEventsByTest: 10, 75 | HMACKey: []byte("testing"), 76 | } 77 | } 78 | 79 | func NewTestStorage(cfg *Config) *ExportStorage { 80 | strg, err := New(cfg) 81 | if err != nil { 82 | log.Fatalln("NewTestStorage:", err) 83 | } 84 | return &ExportStorage{ 85 | Storage: strg, 86 | } 87 | } 88 | 89 | func NewMockStorage(cfg *Config) *Storage { 90 | hmac := NewTestHMAC(cfg.HMACKey) 91 | return &Storage{ 92 | tests: make(map[string]test), 93 | maxTests: cfg.MaxEvents / cfg.MaxEventsByTest, 94 | hmac: hmac, 95 | cfg: *cfg, 96 | } 97 | } 98 | 99 | func NewTestHMAC(key []byte) hash.Hash { 100 | hmac, err := blake2b.New256(key) 101 | if err != nil { 102 | log.Fatalln("NewTestHMAC:", err) 103 | } 104 | return hmac 105 | } 106 | 107 | func NewTestEvent() app.Event { 108 | return app.Event{ 109 | ID: string(RandBytes(16)), 110 | Time: time.Now(), 111 | TestID: TTest.id, 112 | Receiver: "TEST Receiver", 113 | RemoteAddr: "203.0.113.113", 114 | Dump: "TEST Dump", 115 | QueryType: "TEST QueryType", 116 | } 117 | } 118 | 119 | type ExportEventHeap struct { 120 | *eventHeap 121 | } 122 | 123 | func NewEmptyEventsHeap() *ExportEventHeap { 124 | return &ExportEventHeap{eventHeap: &eventHeap{}} 125 | } 126 | 127 | func RandBytes(l int) []byte { 128 | rand.Seed(time.Now().UnixNano()) 129 | b := make([]byte, l) 130 | for i := range b { 131 | b[i] = byte(rand.Intn(255)) 132 | } 133 | return b 134 | } 135 | -------------------------------------------------------------------------------- /storage/heap.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | app "github.com/ciphermarco/BOAST" 5 | "github.com/ciphermarco/BOAST/log" 6 | ) 7 | 8 | type eventHeap []app.Event 9 | 10 | func (h eventHeap) Len() int { return len(h) } 11 | func (h eventHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } 12 | 13 | func (h eventHeap) Less(i, j int) bool { 14 | return h[i].Time.Before(h[j].Time) 15 | } 16 | 17 | func (h *eventHeap) Push(x interface{}) { 18 | v, ok := x.(app.Event) 19 | if !ok { 20 | log.Info("An error occurred and an event could not be pushed to the events heap") 21 | log.Debug("eventHeap.Push got data of type %T but wanted boast.Event", v) 22 | } else { 23 | *h = append(*h, v) 24 | } 25 | } 26 | 27 | func (h *eventHeap) Pop() interface{} { 28 | old := *h 29 | n := len(old) 30 | x := old[n-1] 31 | *h = old[0 : n-1] 32 | return x 33 | } 34 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "container/heap" 5 | "errors" 6 | "fmt" 7 | "hash" 8 | "sync" 9 | "time" 10 | 11 | app "github.com/ciphermarco/BOAST" 12 | "github.com/ciphermarco/BOAST/log" 13 | 14 | "golang.org/x/crypto/blake2b" 15 | ) 16 | 17 | // Config represents the storage's configurable options. 18 | type Config struct { 19 | TTL time.Duration 20 | CheckInterval time.Duration 21 | MaxRestarts int 22 | MaxEvents int 23 | MaxEventsByTest int 24 | MaxDumpSize int 25 | HMACKey []byte 26 | } 27 | 28 | // Storage represents the storage itself, holding its configurations and state. 29 | type Storage struct { 30 | mu sync.RWMutex 31 | tests map[string]test 32 | maxTests int 33 | totalTests int 34 | totalEvents int 35 | hmac hash.Hash 36 | cfg Config 37 | } 38 | 39 | // test represents a test of this application. 40 | // A test is identified by an id. It holds a canary token to be used in the response 41 | // from receivers when it may aid testing and recorded events for this test's id. 42 | type test struct { 43 | id string 44 | canary string 45 | events *eventHeap 46 | } 47 | 48 | // New contains the logic to construct and return a new *Storage according to the passed 49 | // *Config object. In case of error, it returns the error to the caller. 50 | func New(cfg *Config) (*Storage, error) { 51 | hmac, err := blake2b.New256(cfg.HMACKey) 52 | if err != nil { 53 | return nil, err 54 | } 55 | maxTests := 0 56 | if cfg.MaxEvents > 0 && cfg.MaxEventsByTest > 0 { 57 | maxTests = cfg.MaxEvents / cfg.MaxEventsByTest 58 | } 59 | s := &Storage{ 60 | tests: make(map[string]test), 61 | maxTests: maxTests, 62 | hmac: hmac, 63 | cfg: *cfg, 64 | } 65 | return s, nil 66 | } 67 | 68 | // SetTest creates a new test or fetches an existing one to return a newly generated or 69 | // already existing test id. In case of error, it returns the error to the caller. 70 | func (s *Storage) SetTest(secret []byte) (id string, canary string, err error) { 71 | s.mu.Lock() 72 | defer s.mu.Unlock() 73 | sum := s.unsafeHmac(secret) 74 | id, canary = app.ToBase32(sum[:len(sum)/2]), app.ToBase32(sum[len(sum)/2:]) 75 | if t, exists := s.tests[id]; exists { 76 | return t.id, t.canary, nil 77 | } else if s.totalTests < s.maxTests { 78 | events := &eventHeap{} 79 | heap.Init(events) 80 | s.tests[id] = test{ 81 | id: id, 82 | canary: canary, 83 | events: events, 84 | } 85 | s.totalTests++ 86 | return id, canary, nil 87 | } 88 | return "", "", errors.New("could not create test") 89 | } 90 | 91 | // SearchTest receives a function to be run against each tests' id and canary, and 92 | // returns the id and canary of the first test for which the passed function returns 93 | // true. If the function never returns true empty strings are returned to the caller. 94 | func (s *Storage) SearchTest(f func(k, v string) bool) (id string, canary string) { 95 | s.mu.RLock() 96 | defer s.mu.RUnlock() 97 | for id, t := range s.tests { 98 | if f(id, t.canary) { 99 | return id, t.canary 100 | } 101 | } 102 | return "", "" 103 | } 104 | 105 | // StoreEvent appends an event to an existing test if it exists, otherwise it will 106 | // return an error to the caller. 107 | func (s *Storage) StoreEvent(evt app.Event) error { 108 | s.mu.Lock() 109 | defer s.mu.Unlock() 110 | id := evt.TestID 111 | if t, exists := s.tests[id]; exists { 112 | if s.cfg.MaxEvents > 0 && s.cfg.MaxEventsByTest > 0 && s.totalEvents <= s.cfg.MaxEvents { 113 | if t.events.Len() >= s.cfg.MaxEventsByTest { 114 | s.unsafePopEvent(id) 115 | } 116 | if len(evt.Dump) > s.cfg.MaxDumpSize { 117 | evt.Dump = evt.Dump[:s.cfg.MaxDumpSize] 118 | } 119 | s.unsafePushEvent(id, evt) 120 | } 121 | return nil 122 | } 123 | return fmt.Errorf("test id %s does not exist", id) 124 | } 125 | 126 | // LoadEvents returns the copy of an test's events slice if the test exists. 127 | func (s *Storage) LoadEvents(id string) (evts []app.Event, loaded bool) { 128 | s.mu.RLock() 129 | defer s.mu.RUnlock() 130 | if t, exists := s.tests[id]; exists { 131 | evts := make([]app.Event, t.events.Len()) 132 | copy(evts, *t.events) 133 | return evts, true 134 | } 135 | return evts, false 136 | } 137 | 138 | // TotalTests returns the number of total tests recorded in the storage at the moment. 139 | func (s *Storage) TotalTests() int { 140 | s.mu.RLock() 141 | defer s.mu.RUnlock() 142 | return s.totalTests 143 | } 144 | 145 | // TotalEvents returns the number of total events recorded in the storage at the moment. 146 | func (s *Storage) TotalEvents() int { 147 | s.mu.RLock() 148 | defer s.mu.RUnlock() 149 | return s.totalEvents 150 | } 151 | 152 | // expire takes care of expiring (i.e. deleting) events according to the configured TTL 153 | // and check interval. 154 | func (s *Storage) expire() (err error) { 155 | defer func() error { 156 | if r := recover(); r != nil { 157 | err = fmt.Errorf("storage expiration error (panic): %v", r) 158 | } 159 | return err 160 | }() 161 | for range time.Tick(s.cfg.CheckInterval) { 162 | s.mu.RLock() 163 | for id, t := range s.tests { 164 | if t.events.Len() == 0 { 165 | s.mu.RUnlock() 166 | s.mu.Lock() 167 | 168 | s.unsafeDeleteTest(id) 169 | 170 | s.mu.Unlock() 171 | s.mu.RLock() 172 | continue 173 | } 174 | ttl := s.cfg.TTL 175 | for t.events.Len() > 0 && time.Since((*t.events)[0].Time) > ttl { 176 | s.mu.RUnlock() 177 | s.mu.Lock() 178 | 179 | s.unsafePopEvent(id) 180 | 181 | s.mu.Unlock() 182 | s.mu.RLock() 183 | } 184 | } 185 | s.mu.RUnlock() 186 | } 187 | return errors.New("storage expiration error") 188 | } 189 | 190 | // StartExpire is used by the caller to start expiring events and, in case of a panic 191 | // from expire function, try to restart the expiration process the configured number of 192 | // times. 193 | // 194 | // Panics should not and are not expected to happen as a normal occurrence, so this may 195 | // be useless and soon to be dropped. 196 | func (s *Storage) StartExpire(ret chan error) { 197 | err := s.expire() 198 | for i := 0; i < s.cfg.MaxRestarts; i++ { 199 | log.Info("Events expiration stopped. Restarting. (%d)\n", i+1) 200 | log.Debug("Storage.StartExpire error: %v", err) 201 | err = s.expire() 202 | } 203 | ret <- err 204 | } 205 | 206 | // unsafePushEvent pushes an event to an test's events leaving the mutex lock to the caller. 207 | // It's unsafe to be used without setting the appropriate lock externally. 208 | func (s *Storage) unsafePushEvent(id string, evt app.Event) { 209 | if t, exists := s.tests[id]; exists { 210 | heap.Push(t.events, evt) 211 | s.totalEvents++ 212 | } 213 | 214 | } 215 | 216 | // unsafePopEvent pops an event from an test's events leaving the mutex lock to the caller. 217 | // It's unsafe to be used without setting the appropriate lock externally. 218 | func (s *Storage) unsafePopEvent(id string) { 219 | if t, exists := s.tests[id]; exists { 220 | heap.Pop(t.events) 221 | s.totalEvents-- 222 | if t.events.Len() == 0 { 223 | s.unsafeDeleteTest(id) 224 | } 225 | } 226 | } 227 | 228 | func (s *Storage) unsafeDeleteTest(id string) { 229 | delete(s.tests, id) 230 | s.totalTests-- 231 | } 232 | 233 | // unsafeHmac uses the storage's hmac and passed bytes to return an HMAC'd sum. 234 | // It's unsafe to be used without setting the appropriate lock externally. 235 | func (s *Storage) unsafeHmac(secret []byte) []byte { 236 | s.hmac.Reset() 237 | s.hmac.Write(secret) 238 | return s.hmac.Sum(nil) 239 | } 240 | -------------------------------------------------------------------------------- /storage/storage_test.go: -------------------------------------------------------------------------------- 1 | package storage_test 2 | 3 | import ( 4 | "container/heap" 5 | "io/ioutil" 6 | 7 | "math/rand" 8 | "os" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | app "github.com/ciphermarco/BOAST" 14 | "github.com/ciphermarco/BOAST/log" 15 | "github.com/ciphermarco/BOAST/storage" 16 | ) 17 | 18 | type testEnv struct { 19 | strg *storage.ExportStorage 20 | cfg *storage.Config 21 | } 22 | 23 | func newTestEnv() *testEnv { 24 | cfg := storage.NewTestConfig() 25 | return &testEnv{ 26 | strg: storage.NewTestStorage(cfg), 27 | cfg: cfg, 28 | } 29 | } 30 | 31 | func TestMain(m *testing.M) { 32 | log.SetOutput(ioutil.Discard) 33 | os.Exit(m.Run()) 34 | } 35 | 36 | func TestNew(t *testing.T) { 37 | env := newTestEnv() 38 | 39 | want := storage.NewMockStorage(env.cfg) 40 | got, err := storage.New(env.cfg) 41 | 42 | if err != nil { 43 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 44 | } 45 | 46 | if !reflect.DeepEqual(want, got) { 47 | t.Errorf("wrong storage") 48 | t.Errorf("Want:") 49 | t.Errorf("%+v", want) 50 | t.Errorf("Got:") 51 | t.Errorf("%+v", got) 52 | } 53 | 54 | tCfg := storage.NewTestConfig() 55 | tCfg.HMACKey = storage.RandBytes(65) 56 | _, gotErr := storage.New(tCfg) 57 | 58 | if gotErr == nil { 59 | t.Errorf("did not fail: error (want) != %v (got)", gotErr) 60 | } 61 | } 62 | 63 | func TestSetTestBasic(t *testing.T) { 64 | env := newTestEnv() 65 | 66 | wantID := storage.TTest.ID() 67 | wantCanary := storage.TTest.Canary() 68 | gotID, gotCanary, err := env.strg.SetTest(storage.TTest.Secret) 69 | 70 | if err != nil { 71 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 72 | } 73 | if wantID != gotID { 74 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, gotID) 75 | } 76 | if wantCanary != gotCanary { 77 | t.Errorf("wrong Canary: %v (want) != %v (got)", wantCanary, gotCanary) 78 | } 79 | 80 | wantTotal := 1 81 | gotTotal := env.strg.TotalTests() 82 | 83 | if wantTotal != gotTotal { 84 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 85 | } 86 | 87 | // wantID does not change 88 | // wantCanary does not change 89 | gotID, gotCanary, err = env.strg.SetTest(storage.TTest.Secret) 90 | 91 | if err != nil { 92 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 93 | } 94 | if wantID != gotID { 95 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, gotID) 96 | } 97 | if wantCanary != gotCanary { 98 | t.Errorf("wrong Canary: %v (want) != %v (got)", wantCanary, gotCanary) 99 | } 100 | 101 | // wantTotal does not change 102 | gotTotal = env.strg.TotalTests() 103 | 104 | if wantTotal != gotTotal { 105 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 106 | } 107 | 108 | } 109 | 110 | func TestCreateSameTestMultipleTimes(t *testing.T) { 111 | env := newTestEnv() 112 | 113 | wantID := storage.TTest.ID() 114 | wantCanary := storage.TTest.Canary() 115 | wantTotal := 1 116 | rand.Seed(time.Now().UnixNano()) 117 | for i := 0; i < rand.Intn(env.strg.MaxTests()); i++ { 118 | gotID, gotCanary, err := env.strg.SetTest(storage.TTest.Secret) 119 | 120 | if err != nil { 121 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 122 | } 123 | if wantID != gotID { 124 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, gotID) 125 | break 126 | } 127 | if wantCanary != gotCanary { 128 | t.Errorf("wrong Canary: %v (want) != %v (got)", wantCanary, gotCanary) 129 | } 130 | 131 | gotTotal := env.strg.TotalTests() 132 | 133 | if wantTotal != gotTotal { 134 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 135 | break 136 | } 137 | } 138 | } 139 | 140 | func TestCreateDifferengTests(t *testing.T) { 141 | env := newTestEnv() 142 | 143 | rand.Seed(time.Now().UnixNano()) 144 | wantTotal := rand.Intn(env.strg.MaxTests()) 145 | 146 | for i := 0; i < wantTotal; i++ { 147 | _, _, err := env.strg.SetTest(storage.RandBytes(32)) 148 | if err != nil { 149 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 150 | break 151 | } 152 | } 153 | 154 | gotTotal := env.strg.TotalTests() 155 | 156 | if wantTotal != gotTotal { 157 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 158 | } 159 | } 160 | 161 | func TestSetTestLimit(t *testing.T) { 162 | env := newTestEnv() 163 | 164 | wantmaxTests := env.strg.MaxEvents() / env.strg.MaxEventsByTest() 165 | gotmaxTests := env.strg.MaxTests() 166 | 167 | if wantmaxTests != gotmaxTests { 168 | t.Errorf("wrong max tests: %v (want) != %v (got)", wantmaxTests, gotmaxTests) 169 | } 170 | 171 | for i := 0; i < gotmaxTests; i++ { 172 | _, _, err := env.strg.SetTest(storage.RandBytes(8)) 173 | if err != nil { 174 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 175 | break 176 | } 177 | } 178 | 179 | _, _, err := env.strg.SetTest(storage.RandBytes(8)) 180 | if err == nil { 181 | t.Errorf("did not fail: error (want) != %v (got)", err) 182 | } 183 | } 184 | 185 | func TestSearchTest(t *testing.T) { 186 | env := newTestEnv() 187 | f := func(searchID string) func(key, value string) bool { 188 | return func(key, value string) bool { 189 | return searchID == key 190 | } 191 | } 192 | 193 | wantID, wantCanary := "", "" 194 | gotID, gotCanary := env.strg.SearchTest(f(wantID)) 195 | 196 | if wantID != gotID { 197 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, gotID) 198 | } 199 | if wantCanary != gotCanary { 200 | t.Errorf("wrong canary: %v (want) != %v (got)", wantCanary, gotCanary) 201 | } 202 | 203 | env.strg.SetTest(storage.TTest.Secret) 204 | 205 | rand.Seed(time.Now().UnixNano()) 206 | for i := 0; i < rand.Intn(11); i++ { 207 | env.strg.SetTest(storage.RandBytes(8)) 208 | } 209 | 210 | wantID, wantCanary = storage.TTest.ID(), storage.TTest.Canary() 211 | gotID, gotCanary = env.strg.SearchTest(f(wantID)) 212 | 213 | if wantID != gotID { 214 | t.Errorf("wrong ID: %v (want) != %v (got)", wantID, gotID) 215 | } 216 | if wantCanary != gotCanary { 217 | t.Errorf("wrong canary: %v (want) != %v (got)", wantCanary, gotCanary) 218 | } 219 | } 220 | 221 | func TestStoreEvent(t *testing.T) { 222 | env := newTestEnv() 223 | evt := storage.NewTestEvent() 224 | 225 | err := env.strg.StoreEvent(evt) 226 | if err == nil { 227 | t.Errorf("did not fail: error (want) != %v (got)", err) 228 | } 229 | 230 | env.strg.SetTest(storage.TTest.Secret) 231 | err = env.strg.StoreEvent(evt) 232 | if err != nil { 233 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 234 | } 235 | } 236 | 237 | func TestStoreEventLimit(t *testing.T) { 238 | env := newTestEnv() 239 | evt := storage.NewTestEvent() 240 | env.strg.SetTest(storage.TTest.Secret) 241 | 242 | totalEvts := env.strg.MaxEventsByTest() + 10 243 | for i := 0; i < totalEvts; i++ { 244 | err := env.strg.StoreEvent(evt) 245 | if err != nil { 246 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 247 | break 248 | } 249 | } 250 | 251 | wantTotal := env.strg.MaxEventsByTest() 252 | gotTotal := env.strg.TotalEvents() 253 | 254 | if wantTotal != gotTotal { 255 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 256 | } 257 | } 258 | 259 | func TestLoadEvents(t *testing.T) { 260 | env := newTestEnv() 261 | evt := storage.NewTestEvent() 262 | env.strg.SetTest(storage.TTest.Secret) 263 | 264 | totalEvts := env.strg.MaxEventsByTest() + 10 265 | for i := 0; i < totalEvts; i++ { 266 | err := env.strg.StoreEvent(evt) 267 | if err != nil { 268 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 269 | break 270 | } 271 | } 272 | 273 | wantLoaded := true 274 | wantTotal := env.strg.MaxEventsByTest() 275 | gotEvts, gotLoaded := env.strg.LoadEvents(storage.TTest.ID()) 276 | gotTotal := len(gotEvts) 277 | 278 | if wantLoaded != gotLoaded { 279 | t.Errorf("response was not set as loaded: %v (want) != %v (got)", wantLoaded, gotLoaded) 280 | } 281 | if wantTotal != gotTotal { 282 | t.Errorf("wrong length: %v (want) != %v (got)", wantTotal, gotTotal) 283 | } 284 | 285 | var wantEvts []app.Event 286 | wantLoaded = false 287 | gotEvts, gotLoaded = env.strg.LoadEvents(string(storage.RandBytes(8))) 288 | 289 | if wantLoaded != gotLoaded { 290 | t.Errorf("response was set as loaded: %v (want) != %v (got)", wantLoaded, gotLoaded) 291 | } 292 | if !reflect.DeepEqual(wantEvts, gotEvts) { 293 | t.Errorf("wrong slice: %v (want) != %v (got)", wantEvts, gotEvts) 294 | } 295 | } 296 | 297 | func TestTotalTests(t *testing.T) { 298 | env := newTestEnv() 299 | 300 | wantTotal := env.strg.MaxTests() - 1 301 | for i := 0; i < wantTotal; i++ { 302 | env.strg.SetTest(storage.RandBytes(8)) 303 | } 304 | gotTotal := env.strg.TotalTests() 305 | 306 | if wantTotal != gotTotal { 307 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 308 | } 309 | } 310 | 311 | func TestTotalEvents(t *testing.T) { 312 | env := newTestEnv() 313 | evt := storage.NewTestEvent() 314 | env.strg.SetTest(storage.TTest.Secret) 315 | 316 | totalEvts := env.strg.MaxEventsByTest() - 2 317 | for i := 0; i < totalEvts; i++ { 318 | err := env.strg.StoreEvent(evt) 319 | if err != nil { 320 | t.Errorf("unexpected error: %v (want) != %v (got)", nil, err) 321 | } 322 | } 323 | 324 | wantTotal := totalEvts 325 | gotTotal := env.strg.TotalEvents() 326 | 327 | if wantTotal != gotTotal { 328 | t.Errorf("wrong total: %v (want) != %v (got)", wantTotal, gotTotal) 329 | } 330 | } 331 | 332 | func TestExpiration(t *testing.T) { 333 | check := func(want, got int) { 334 | if want != got { 335 | t.Errorf("wrong total: %v (want) != %v (got)", want, got) 336 | } 337 | } 338 | 339 | tCfg := storage.NewTestConfig() 340 | tCfg.TTL = 500 * time.Millisecond 341 | tCfg.CheckInterval = 1 * time.Millisecond 342 | tStrg := storage.NewTestStorage(tCfg) 343 | tStrg.SetTest(storage.TTest.Secret) 344 | // sets a test meant to keep without events 345 | tStrg.SetTest([]byte("2sqGqj4FQubefsqqiEksJg==")) 346 | 347 | totalEvts := 3 348 | for i := 0; i < totalEvts; i++ { 349 | err := tStrg.StoreEvent(storage.NewTestEvent()) 350 | if err != nil { 351 | t.Fatal(err) 352 | } 353 | wantTotal := i + 1 354 | check(wantTotal, tStrg.TotalEvents()) 355 | } 356 | 357 | tErr := make(chan error, 1) 358 | go tStrg.StartExpire(tErr) 359 | time.Sleep(tStrg.TTL()) 360 | time.Sleep(tStrg.CheckInterval()) 361 | 362 | wantTotal := 0 363 | check(wantTotal, tStrg.TotalEvents()) 364 | check(wantTotal, tStrg.TotalTests()) 365 | } 366 | 367 | func TestHeapPushWithWrongType(t *testing.T) { 368 | defer func() { 369 | if r := recover(); r != nil { 370 | t.Errorf("function panicked: r == %v (want) r == \"%v\" (got)", nil, r) 371 | } 372 | }() 373 | evts := storage.NewEmptyEventsHeap() 374 | heap.Init(evts) 375 | wrongType := "not an event" 376 | heap.Push(evts, wrongType) 377 | 378 | wantTotal := 0 379 | gotTotal := evts.Len() 380 | 381 | if wantTotal != gotTotal { 382 | t.Errorf("wrong length: %v (want) != %v (got)", wantTotal, gotTotal) 383 | } 384 | } 385 | 386 | var evtsBench []app.Event 387 | var loadedBench bool 388 | 389 | func BenchmarkLoadEvents(b *testing.B) { 390 | tCfg := storage.NewTestConfig() 391 | tCfg.MaxEvents = 10_000 392 | tCfg.MaxEventsByTest = 10_000 393 | tStrg := storage.NewTestStorage(tCfg) 394 | id, _, err := tStrg.SetTest(storage.TTest.Secret) 395 | if err != nil { 396 | b.Fatal(err) 397 | } 398 | 399 | evt := storage.NewTestEvent() 400 | for i := 0; i < tCfg.MaxEvents; i++ { 401 | tStrg.StoreEvent(evt) 402 | } 403 | 404 | var evts []app.Event 405 | var loaded bool 406 | for n := 0; n < b.N; n++ { 407 | evts, loaded = tStrg.LoadEvents(id) 408 | } 409 | 410 | // To avoid compiler optimisations that could eliminate the function 411 | // call if the value is not used. 412 | evtsBench, loadedBench = evts, loaded 413 | } 414 | 415 | var idBench string 416 | var canaryBench string 417 | 418 | func BenchmarkSearchTest(b *testing.B) { 419 | tCfg := storage.NewTestConfig() 420 | tCfg.MaxEvents = 10_000 421 | tCfg.MaxEventsByTest = 1 422 | tStrg := storage.NewTestStorage(tCfg) 423 | _, _, err := tStrg.SetTest(storage.TTest.Secret) 424 | if err != nil { 425 | b.Fatal(err) 426 | } 427 | f := func(searchID string) func(key, value string) bool { 428 | return func(key, value string) bool { 429 | return searchID == key 430 | } 431 | } 432 | 433 | for i := 0; i < tStrg.MaxTests(); i++ { 434 | tStrg.SetTest(storage.RandBytes(8)) 435 | } 436 | 437 | var id string 438 | var canary string 439 | for n := 0; n < b.N; n++ { 440 | id, canary = tStrg.SearchTest(f("unexistent test id")) 441 | } 442 | 443 | // To avoid compiler optimisations that could eliminate the function 444 | // call if the value is not used. 445 | idBench, canaryBench = id, canary 446 | } 447 | -------------------------------------------------------------------------------- /testdata/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDZTCCAk2gAwIBAgIUXtjAilBjLs9ejtzGZ34ngZgISagwDQYJKoZIhvcNAQEL 3 | BQAwQjELMAkGA1UEBhMCWFgxFTATBgNVBAcMDERlZmF1bHQgQ2l0eTEcMBoGA1UE 4 | CgwTRGVmYXVsdCBDb21wYW55IEx0ZDAeFw0yMDA5MjIxODAwMDBaFw0zMDA5MjAx 5 | ODAwMDBaMEIxCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAa 6 | BgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IB 7 | DwAwggEKAoIBAQCWGtnVFanyY2pZ2RSqgvOTze4Z/lRUJXrsF5rbEZkIdI45ZE7k 8 | Wj2v+c7T62C6ORU1k8df15BpLmMxHyoxRQkW0oU1uA9jySpTYZkqy+kx4mn84BMD 9 | nWgfSGfG7TLAJXWj4h9AU7EwvniW3RUxEcHIi6v93Eh+Kl4gkQwxs7fw1CL5GXsu 10 | kjPCyTbEJv6HyQAqt9HNSgKpJrJZYeE664igJpiegoJUFLSYYz/zr0xc4TKJrJje 11 | gpvI166OfsrI8a52k7lXKcGKG0KA0hKgZPbCeORVGxaNK4HQE/36ZYXzLAjKOi+Z 12 | BUxgHKV0Fyybc8DOQQtStyIf03BZ7yBobcTTAgMBAAGjUzBRMB0GA1UdDgQWBBSa 13 | GwX/wtvtQZncf105+GiY4TtBXzAfBgNVHSMEGDAWgBSaGwX/wtvtQZncf105+GiY 14 | 4TtBXzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQANnbhMBq+k 15 | OQtUBluaFBFMxaAF7L48YHe8z0Jk+ZDHS5mGTRiXakuz9d7Ys0KNztiRRi82ykcw 16 | DBXrVcEBLNjDN05/7HQFTssmNBVXA0VsGzKyaFdkQ442QmEYIdZWjk6sFqLTpiaA 17 | 52gbwqOXbtQj3moLHqf3IuedndLHi82rilTceNYl01AdWyMLw11UNcFM4P0kuGab 18 | 0vBn0HriInE4DXBrQxoSmfidTTdC1Tmi9Aue7YTllfgBPzUShmN/T62tjH4FHSN/ 19 | WanzRET02S/0+yhsE+OzFlcehVg4u+IpeZAJ9ub/9DcRYOmYMSJLCqImtMFZom63 20 | moNl3BJ7Gg94 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /testdata/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAlhrZ1RWp8mNqWdkUqoLzk83uGf5UVCV67Bea2xGZCHSOOWRO 3 | 5Fo9r/nO0+tgujkVNZPHX9eQaS5jMR8qMUUJFtKFNbgPY8kqU2GZKsvpMeJp/OAT 4 | A51oH0hnxu0ywCV1o+IfQFOxML54lt0VMRHByIur/dxIfipeIJEMMbO38NQi+Rl7 5 | LpIzwsk2xCb+h8kAKrfRzUoCqSayWWHhOuuIoCaYnoKCVBS0mGM/869MXOEyiayY 6 | 3oKbyNeujn7KyPGudpO5VynBihtCgNISoGT2wnjkVRsWjSuB0BP9+mWF8ywIyjov 7 | mQVMYByldBcsm3PAzkELUrciH9NwWe8gaG3E0wIDAQABAoIBAQCCee6VuZITPvVo 8 | Cjlbih6+gMeSUq/swPObm10hRae3YNFr89RbzFFI0SVGsphO52WXP9CTb+Z4dzkD 9 | rupXD4I6E151dnvyKh+fgPvJ5pvan8uvYvtELiQe5SpIEVEHEsiyXtD5coZYL4jU 10 | 4nIUSDIg57/mF//vo1ZUiqCF54lhTUANO0p2fs+7RI4GfmAqbz77ZIyr5DepunQs 11 | p3lJMHcmdSWkzeSPnreCDpGtv3w76ev0+VxVRxyYy1Jvt8q0w42ePX4MOTa5Sl0C 12 | gvNMr32K1dS1tvu8JRkbcsyc7OMJpIsUg2o+SdkuZRU8ewZPIEtQJ6zCk8017b0T 13 | HOOh2nrRAoGBAMdu3hAdMLnfQkH9RWuhF469J3CaLBiG8sJQBZVb9Y/hvE/B+1or 14 | PYfUGuQJkRBgBHvHJpVhA/MCwjPgLsgVgwuQmANMFSuVA2wTxLcJ1E5pmlUZ021t 15 | 0xB2fhcqFBwsrpZ/2461qLTIDXYRT5qT42kdLm+iWDF3QDWXJ0EztSH5AoGBAMCu 16 | MLA3JUsykV8dOUpZX7LNJTXmTw5+UR76hynM3F1B9QyTsa0EbiYk0sCQ3rI20FtA 17 | oeV+2rmUw7Vdm1kC6vlxBHD6mIr2la1NsuuYNM193rEd+Pi+WM9k9JKmaSrws9PY 18 | yAhHbOXt6INKyAZ2yI3LnifKvPX/lbpkCsAOTZArAoGAXcvX5w5Dh3foaq7awocO 19 | VFTEQuJP0O1PKXKHXbrVYGlTrtNWCw+BLevlBdE2B9SQ50I/9EufluB6Q/mxJutv 20 | KbZEuHBFGK1J4b/eahPWZVanflTaKoJXnUuNfAmPUbz2E9Roh9MKWJQqOJhlrxbV 21 | Au/1kg1xmzox2cKQdMsD6skCgYBHPuGf9vQiRxN70QmDFWMOcU6mDIAFAu4p/0cF 22 | TMva6+2Zde9H45B7KDiJncfKq/wFEfQLMQndf0WShYdQtYR/MawLvo2zLJSR3V4g 23 | QUqdBULXyRZrm66pGVJZ+5B9oT1NQyZL8WUx6/OCwJ8PzNJBpB3Z5txSNex+XEmh 24 | VGiXuwKBgQCcE4+dGkzvwM+qxYEHhHrFt3kJMxEbZ709uhw8e8pcRsubD7zTkWRe 25 | s/nyEqBl2G/A18mL6th4YedOIWSCHxIHiGUMTR7zu86Yp7NT6JSNB6Z51JgvXSza 26 | WbQQjvALTXNPg8LAF1RrXeSXb/mZ6IyRNayiLjv5nGyyB843vtprbw== 27 | -----END RSA PRIVATE KEY----- 28 | --------------------------------------------------------------------------------