├── api
├── httplogger
│ ├── middlewares_test.go
│ ├── LICENSE
│ ├── wrap_writer_test.go
│ ├── logger.go
│ └── wrap_writer.go
├── export_test.go
├── mock_test.go
├── authorization.go
├── server.go
├── routes.go
└── authorization_test.go
├── docs
├── boast.png
├── boast-configuration.md
├── interacting.md
└── deploying.md
├── config
├── export_test.go
├── config.go
└── config_test.go
├── receivers
├── httprcv
│ ├── export_test.go
│ ├── mock_test.go
│ ├── httprcv_test.go
│ └── httprcv.go
└── dnsrcv
│ ├── export_test.go
│ ├── mock_test.go
│ ├── dnsrcv.go
│ └── dnsrcv_test.go
├── NOTICE
├── storage
├── heap.go
├── export_test.go
├── storage.go
└── storage_test.go
├── Makefile
├── go.mod
├── examples
├── config
│ ├── development-example.toml
│ └── production-example.toml
└── bash_client
│ └── client.sh
├── testdata
├── cert.pem
└── key.pem
├── README.md
├── log
├── log.go
└── log_test.go
├── boast.go
├── boast_test.go
├── go.sum
├── cmd
└── boast
│ └── main.go
└── LICENSE
/api/httplogger/middlewares_test.go:
--------------------------------------------------------------------------------
1 | package httplogger
2 |
--------------------------------------------------------------------------------
/docs/boast.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahwul/BOAST/master/docs/boast.png
--------------------------------------------------------------------------------
/config/export_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | var ParseByteSize = parseByteSize
4 |
--------------------------------------------------------------------------------
/receivers/httprcv/export_test.go:
--------------------------------------------------------------------------------
1 | package httprcv
2 |
3 | var CatchAll = catchAll
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------