├── .gitattributes ├── .github ├── dish_run.png ├── dish_telegram.png ├── workflows │ ├── coverage.yml │ └── tests.yml └── PULL_REQUEST_TEMPLATE.md ├── go.mod ├── go.sum ├── .dockerignore ├── .gitignore ├── pkg ├── socket │ ├── utils.go │ ├── utils_test.go │ ├── cache.go │ ├── socket.go │ ├── helpers_test.go │ ├── socket_test.go │ ├── cache_test.go │ ├── fetch_test.go │ └── fetch.go ├── netrunner │ ├── runner_windows.go │ ├── helpers_test.go │ ├── runner.go │ ├── runner_posix.go │ └── runner_test.go ├── alert │ ├── formatter.go │ ├── alerter_test.go │ ├── url.go │ ├── alerter.go │ ├── webhook.go │ ├── telegram.go │ ├── api.go │ ├── url_test.go │ ├── discord.go │ ├── transport.go │ ├── pushgateway.go │ ├── formatter_test.go │ ├── telegram_test.go │ ├── notifier.go │ ├── discord_test.go │ ├── helpers_test.go │ ├── webhook_test.go │ ├── api_test.go │ ├── pushgateway_test.go │ └── notifier_test.go ├── logger │ ├── logger_test.go │ ├── logger.go │ ├── console_logger.go │ └── console_logger_test.go └── config │ ├── config_test.go │ └── config.go ├── .env.example ├── sonar-project.properties ├── cmd └── dish │ ├── cli.go │ ├── cli_test.go │ ├── helpers_test.go │ ├── main.go │ ├── main_test.go │ ├── runner_test.go │ └── runner.go ├── configs ├── prometheus-alert.yml └── demo_sockets.json ├── docker-compose.test.yml ├── .gitlab-ci.yml ├── deployments └── docker-compose.yml ├── LICENSE ├── .golangci.yaml ├── Makefile └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go text eol=lf -------------------------------------------------------------------------------- /.github/dish_run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevxn/dish/HEAD/.github/dish_run.png -------------------------------------------------------------------------------- /.github/dish_telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevxn/dish/HEAD/.github/dish_telegram.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.vxn.dev/dish 2 | 3 | go 1.22 4 | 5 | // Test-only dependency 6 | require github.com/google/go-cmp v0.7.0 7 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 2 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/* 2 | .github/* 3 | *.example 4 | *.backup 5 | .gitignore 6 | .env 7 | .env.example 8 | 9 | build/* 10 | deployments/* 11 | 12 | dish 13 | main 14 | 15 | # README.md 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode/* 3 | vendor/ 4 | cache/ 5 | .cache/ 6 | 7 | # binaries 8 | /bin 9 | /dish 10 | main 11 | *.exe 12 | 13 | # test files 14 | cover.* 15 | coverage.out 16 | *.cov 17 | 18 | -------------------------------------------------------------------------------- /pkg/socket/utils.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import "regexp" 4 | 5 | // IsFilePath checks whether input is a file path or URL. 6 | func IsFilePath(source string) bool { 7 | matched, _ := regexp.MatchString("^(http|https)://", source) 8 | return !matched 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # dish / environment constatns 2 | 3 | APP_NAME=dish 4 | APP_VERSION=1.12.0 5 | 6 | APP_FLAGS=-verbose -timeout 10 7 | SOURCE=demo_sockets.json 8 | 9 | ALPINE_VERSION=3.20 10 | GOLANG_VERSION=1.24 11 | 12 | DOCKER_IMAGE_TAG=${APP_NAME}:${APP_VERSION}-go${GOLANG_VERSION} 13 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=infrastructure_dish_d316e6ff-002b-4138-aa78-6100f3f7e944 2 | sonar.qualitygate.wait=true 3 | 4 | sonar.go.coverage.reportPaths=coverage.profile 5 | 6 | sonar.sources=. 7 | sonar.exclusions=**/*_test.go 8 | 9 | sonar.tests=. 10 | sonar.test.inclusions=**/*_test.go 11 | 12 | -------------------------------------------------------------------------------- /cmd/dish/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func printHelp() { 6 | fmt.Print("Usage: dish [FLAGS] SOURCE\n\n") 7 | fmt.Print("A lightweight, one-shot socket checker\n\n") 8 | fmt.Println("SOURCE must be a file path leading to a JSON file with a list of sockets to be checked or a URL leading to a remote JSON API from which the list of sockets can be retrieved") 9 | fmt.Println("Use the `-h` flag for a list of available flags") 10 | } 11 | -------------------------------------------------------------------------------- /configs/prometheus-alert.yml: -------------------------------------------------------------------------------- 1 | --- 2 | groups: 3 | - name: dish 4 | rules: 5 | 6 | # Firing when dish instance isn't sending any new data 7 | - alert: DishStaleLastTime 8 | expr: rate(push_time_seconds{exported_job="dish_results"}[5m]) == 0 9 | for: 1m 10 | labels: 11 | severity: critical 12 | 13 | # Generic dish socket down alert 14 | - alert: DishSocketDown 15 | expr: dish_failed_count > 0 16 | for: 3m 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Generate code coverage badge 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | generate-coverage-badge: 11 | runs-on: ${{ vars.TEST_RUNNER_LABEL }} 12 | name: Update coverage badge 13 | steps: 14 | - name: Update coverage report 15 | uses: ncruces/go-coverage-report@v0 16 | with: 17 | report: true 18 | chart: true 19 | amend: true 20 | continue-on-error: false 21 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | name: ${PROJECT_NAME} 2 | 3 | services: 4 | dish: 5 | image: ${DOCKER_IMAGE_TAG} 6 | container_name: ${DOCKER_TEST_CONTAINER} 7 | restart: no 8 | build: 9 | context: . 10 | dockerfile: build/Dockerfile 11 | target: dish-build 12 | args: 13 | ALPINE_VERSION: ${ALPINE_VERSION} 14 | APP_NAME: ${APP_NAME} 15 | APP_FLAGS: ${APP_FLAGS} 16 | SOURCE: ${SOURCE} 17 | GOLANG_VERSION: ${GOLANG_VERSION} 18 | entrypoint: go 19 | command: test -v ./... -------------------------------------------------------------------------------- /pkg/netrunner/runner_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package netrunner 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | 9 | "go.vxn.dev/dish/pkg/logger" 10 | "go.vxn.dev/dish/pkg/socket" 11 | ) 12 | 13 | type icmpRunner struct { 14 | logger logger.Logger 15 | } 16 | 17 | func (runner icmpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 18 | return socket.Result{Socket: sock, Error: errors.New("icmp tests on windows are not implemented")} 19 | } 20 | 21 | func checksum(_ []byte) uint16 { 22 | return 0 // return invalid checksum since not implemented in Windows 23 | } 24 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | workflow: 2 | rules: 3 | - when: always 4 | 5 | stages: 6 | - test 7 | 8 | 9 | # 10 | # stage test 11 | # 12 | 13 | combined-coverage-sonarqube-check: 14 | stage: test 15 | allow_failure: true 16 | script: 17 | - go clean -testcache 18 | - go test -v -coverprofile ./coverage.profile ./... && go tool cover -func ./coverage.profile 19 | - make sonar_check 20 | 21 | go-test: 22 | stage: test 23 | environment: 24 | name: test 25 | script: 26 | - make test 27 | 28 | go-lint: 29 | stage: test 30 | environment: 31 | name: test 32 | script: 33 | - make lint 34 | - make lint-fix 35 | 36 | -------------------------------------------------------------------------------- /deployments/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # dish / _EXAMPLE_ docker-compose.yml file 2 | # mainly used for dish binary building process, as the binary itself does not serve any HTTP 3 | name: ${PROJECT_NAME} 4 | 5 | services: 6 | dish: 7 | image: ${DOCKER_IMAGE_TAG} 8 | container_name: ${DOCKER_DEV_CONTAINER} 9 | restart: "no" 10 | command: "${APP_FLAGS} ${SOURCE}" 11 | build: 12 | context: .. 13 | dockerfile: build/Dockerfile 14 | args: 15 | ALPINE_VERSION: ${ALPINE_VERSION} 16 | APP_NAME: ${APP_NAME} 17 | APP_FLAGS: ${APP_FLAGS} 18 | SOURCE: ${SOURCE} 19 | GOLANG_VERSION: ${GOLANG_VERSION} 20 | 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Before submitting your PR, make sure the boxes below are ticked: 2 | 3 | - [ ] I have run the linter (`golangci-lint run`) and fixed any issues. 4 | - [ ] I have run all existing tests and they pass. 5 | - [ ] I have added meaningful, concise commit messages, written in an imperative style ("Add files", "Fix logger", etc.). 6 | 7 | 8 | Make sure to check the boxes below if they are applicable to the given situation (of course, it is not necessary to write tests when updating README etc.): 9 | 10 | - [ ] My PR includes tests, covering at least the bare minimum functionality. 11 | - [ ] I have updated documentation (README). 12 | - [ ] I have linked related issues. 13 | -------------------------------------------------------------------------------- /configs/demo_sockets.json: -------------------------------------------------------------------------------- 1 | { 2 | "sockets": [ 3 | { 4 | "id": "vxn_dev_https", 5 | "socket_name": "vxn-dev HTTPS", 6 | "host_name": "https://vxn.dev", 7 | "port_tcp": 443, 8 | "path_http": "/", 9 | "expected_http_code_array": [200] 10 | }, 11 | { 12 | "id": "text_n0p_cz_https", 13 | "socket_name": "text-n0p-cz HTTPS", 14 | "host_name": "https://text.n0p.cz", 15 | "port_tcp": 443, 16 | "path_http": "/?", 17 | "expected_http_code_array": [401] 18 | }, 19 | { 20 | "id": "openttd_TCP", 21 | "socket_name": "openttd TCP", 22 | "host_name": "ottd.vxn.dev", 23 | "port_tcp": 3979, 24 | "path_http": "", 25 | "expected_http_code_array": [] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /pkg/alert/formatter.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.vxn.dev/dish/pkg/socket" 7 | ) 8 | 9 | func FormatMessengerText(result socket.Result) string { 10 | status := "failed" 11 | if result.Passed { 12 | status = "success" 13 | } 14 | 15 | text := fmt.Sprintf("• %s:%d", result.Socket.Host, result.Socket.Port) 16 | 17 | if result.Socket.PathHTTP != "" { 18 | text += result.Socket.PathHTTP 19 | } 20 | 21 | text += " -- " + status 22 | 23 | if status == "failed" { 24 | text += " \u274C" // ❌ 25 | text += " -- " 26 | text += result.Error.Error() 27 | } else { 28 | text += " \u2705" // ✅ 29 | } 30 | 31 | text += "\n" 32 | 33 | return text 34 | } 35 | 36 | func FormatMessengerTextWithHeader(header, body string) string { 37 | return header + "\n\n" + body 38 | } 39 | -------------------------------------------------------------------------------- /pkg/netrunner/helpers_test.go: -------------------------------------------------------------------------------- 1 | package netrunner 2 | 3 | // MockLogger is a mock implementation of the Logger interface with empty method implementations. 4 | type MockLogger struct{} 5 | 6 | func (l *MockLogger) Trace(v ...any) {} 7 | func (l *MockLogger) Tracef(format string, v ...any) {} 8 | func (l *MockLogger) Debug(v ...any) {} 9 | func (l *MockLogger) Debugf(format string, v ...any) {} 10 | func (l *MockLogger) Info(v ...any) {} 11 | func (l *MockLogger) Infof(format string, v ...any) {} 12 | func (l *MockLogger) Warn(v ...any) {} 13 | func (l *MockLogger) Warnf(format string, v ...any) {} 14 | func (l *MockLogger) Error(v ...any) {} 15 | func (l *MockLogger) Errorf(format string, v ...any) {} 16 | func (l *MockLogger) Panic(v ...any) {} 17 | func (l *MockLogger) Panicf(format string, v ...any) {} 18 | -------------------------------------------------------------------------------- /pkg/alert/alerter_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.vxn.dev/dish/pkg/config" 7 | ) 8 | 9 | func TestNewAlerter(t *testing.T) { 10 | mockLogger := MockLogger{} 11 | 12 | if alerterNil := NewAlerter(nil); alerterNil != nil { 13 | t.Error("expected nil, got alerter") 14 | } 15 | 16 | if alerter := NewAlerter(&mockLogger); alerter == nil { 17 | t.Error("expected alerter, got nil") 18 | } 19 | } 20 | 21 | func TestHandleAlerts(t *testing.T) { 22 | var ( 23 | mockConfig = config.Config{} 24 | mockLogger = MockLogger{} 25 | mockResults = Results{} 26 | ) 27 | 28 | alerter := NewAlerter(&mockLogger) 29 | if alerter == nil { 30 | t.Error("expected alerter, got nil") 31 | } 32 | 33 | // HandleAlerts function returns no values, so these checks are to cover 34 | // the body of such function. 35 | alerter.HandleAlerts("", nil, 0, nil) 36 | 37 | alerter.HandleAlerts("HandleAlerts test", &mockResults, 20, &mockConfig) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/socket/utils_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsFilePath(t *testing.T) { 8 | tests := []struct { 9 | source string 10 | expectFile bool 11 | }{ 12 | {"path/file.txt", true}, 13 | {"C:/file.txt", true}, 14 | {"./path", true}, 15 | {"file", true}, 16 | {"", true}, 17 | } 18 | 19 | for _, tt := range tests { 20 | t.Run("Test file path", func(t *testing.T) { 21 | if got := IsFilePath(tt.source); got != tt.expectFile { 22 | t.Errorf("IsFilePath(%q) = %v, want %v", tt.source, got, tt.expectFile) 23 | } 24 | }) 25 | } 26 | 27 | urlTests := []struct { 28 | source string 29 | expectFile bool 30 | }{ 31 | {"https://example.com", false}, 32 | {"http://localhost:8080", false}, 33 | {"https://www.google.com", false}, 34 | } 35 | 36 | for _, tt := range urlTests { 37 | t.Run("Test URL", func(t *testing.T) { 38 | if got := IsFilePath(tt.source); got != tt.expectFile { 39 | t.Errorf("IsFilePath(%s) = %v, want %v", tt.source, got, tt.expectFile) 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 vxn.dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/dish/cli_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestPrintHelp(t *testing.T) { 12 | oldStdout := os.Stdout 13 | r, w, err := os.Pipe() 14 | if err != nil { 15 | t.Errorf("failed to process pipe: %v", err) 16 | } 17 | 18 | os.Stdout = w 19 | 20 | printHelp() 21 | 22 | if err := w.Close(); err != nil { 23 | t.Errorf("pipe close: %v", err) 24 | } 25 | 26 | os.Stdout = oldStdout 27 | 28 | var buf bytes.Buffer 29 | _, err = io.Copy(&buf, r) 30 | if err != nil { 31 | t.Fatalf("failed to read from pipe: %v", err) 32 | } 33 | output := buf.String() 34 | 35 | if !strings.Contains(output, "Usage: dish [FLAGS] SOURCE") { 36 | t.Errorf("help output missing usage line") 37 | } 38 | if !strings.Contains(output, "A lightweight, one-shot socket checker") { 39 | t.Errorf("help output missing description") 40 | } 41 | if !strings.Contains(output, "SOURCE must be a file path") { 42 | t.Errorf("help output missing source description") 43 | } 44 | if !strings.Contains(output, "Use the `-h` flag") { 45 | t.Errorf("help output missing -h flag info") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /cmd/dish/helpers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | // This socket list is used across tests. It contains a socket which should always pass checks (unless the site is actually down). 10 | const testSocketsValid string = `{ "sockets": [ { "id": "vxn_dev_https", "socket_name": "vxn-dev HTTPS", "host_name": "https://vxn.dev", "port_tcp": 443, "path_http": "/", "expected_http_code_array": [200] } ] }` 11 | 12 | // This socket list is used across tests. It contains a socket which should never pass checks. 13 | const testSocketsSomeInvalid string = `{ "sockets": [ { "id": "vxn_dev_https", "socket_name": "vxn-dev HTTPS", "host_name": "https://vxn.dev", "port_tcp": 443, "path_http": "/", "expected_http_code_array": [500] } ] }` 14 | 15 | // testFile creates a temporary file inside of a temporary directory with the provided filename and data. 16 | // The temporary directory including the file is removed when the test using it finishes. 17 | func testFile(t *testing.T, filename string, data []byte) string { 18 | t.Helper() 19 | dir := t.TempDir() 20 | 21 | filepath := filepath.Join(dir, filename) 22 | 23 | err := os.WriteFile(filepath, data, 0o600) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | return filepath 29 | } 30 | -------------------------------------------------------------------------------- /pkg/alert/url.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | var defaultSchemes = []string{"http", "https"} 11 | 12 | // parseAndValidateURL parses and validates a URL with strict scheme requirements. 13 | // The supportedSchemes parameter allows customizing allowed protocols (defaults to HTTP/HTTPS if nil). 14 | func parseAndValidateURL(rawURL string, supportedSchemes []string) (*url.URL, error) { 15 | if strings.TrimSpace(rawURL) == "" { 16 | return nil, fmt.Errorf("URL cannot be empty") 17 | } 18 | 19 | if supportedSchemes == nil { 20 | supportedSchemes = defaultSchemes 21 | } 22 | 23 | parsedURL, err := url.ParseRequestURI(rawURL) 24 | if err != nil { 25 | return nil, fmt.Errorf("error parsing URL: %w", err) 26 | } 27 | 28 | switch { 29 | case parsedURL.Scheme == "": 30 | return nil, fmt.Errorf("protocol must be specified in the provided URL (e.g. https://...)") 31 | 32 | case !slices.Contains(supportedSchemes, parsedURL.Scheme): 33 | return nil, fmt.Errorf("unsupported protocol provided in URL: %s (supported protocols: %v)", parsedURL.Scheme, supportedSchemes) 34 | 35 | case parsedURL.Host == "": 36 | return nil, fmt.Errorf("URL must contain a host") 37 | } 38 | 39 | return parsedURL, nil 40 | } 41 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | issues: 4 | max-same-issues: 50 5 | 6 | formatters: 7 | enable: 8 | - goimports # checks if the code and import statements are formatted according to the 'goimports' command 9 | - gofumpt # Or "gofmt", # Enforce standard formatting 10 | 11 | linters: 12 | enable: 13 | - errcheck #Errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases. 14 | - govet # Vet examines Go source code and reports suspicious constructs. It is roughly the same as 'go vet' and uses its passes. [auto-fix] 15 | - ineffassign # Detects when assignments to existing variables are not used. [fast] 16 | - staticcheck # It's a set of rules from staticcheck. It's not the same thing as the staticcheck binary. The author of staticcheck doesn't support or approve the use of staticcheck as a library inside golangci-lint. [auto-fix] 17 | - unused # Checks Go code for unused constants, variables, functions and types. 18 | # Subective additional linters 19 | - gocyclo # or "cyclop", # Detect cyclomatic complexity 20 | - goconst # Detect repeated values that can be made constants 21 | - misspell # Fix spelling errors 22 | - unconvert # Detect unnecessary type conversions 23 | - unparam # Detect unused function parameters 24 | - dupword # Detect duplicate words in comments and string literals (e.g. “the the”, “is is”) 25 | -------------------------------------------------------------------------------- /pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestLogLevel_Color(t *testing.T) { 9 | tests := []struct { 10 | level logLevel 11 | expected string 12 | }{ 13 | {TRACE, "\033[34m"}, 14 | {DEBUG, "\033[36m"}, 15 | {INFO, "\033[32m"}, 16 | {WARN, "\033[33m"}, 17 | {ERROR, "\033[31m"}, 18 | {PANIC, "\033[35m"}, 19 | {logLevel(999), "\033[0m"}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(fmt.Sprintf("Level_%v", tt.level), func(t *testing.T) { 24 | got := tt.level.Color() 25 | if got != tt.expected { 26 | t.Errorf("color is: %q, but should be: %q", got, tt.expected) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestLogLevel_Prefix(t *testing.T) { 33 | tests := []struct { 34 | level logLevel 35 | withColor bool 36 | expectPart string 37 | }{ 38 | {TRACE, false, "[ TRACE ]: "}, 39 | {DEBUG, false, "[ DEBUG ]: "}, 40 | {INFO, false, "[ INFO ]: "}, 41 | {WARN, false, "[ WARN ]: "}, 42 | {ERROR, false, "[ ERROR ]: "}, 43 | {PANIC, false, "[ PANIC ]: "}, 44 | {logLevel(999), false, "[ UNKNOWN ]: "}, 45 | {TRACE, true, "\033[34m[ TRACE ]\033[0m: "}, 46 | {logLevel(999), true, "[ UNKNOWN ]: "}, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(fmt.Sprintf("Level_%v_Color_%v", tt.level, tt.withColor), func(t *testing.T) { 51 | got := tt.level.Prefix(tt.withColor) 52 | if tt.expectPart != got { 53 | t.Errorf("prefix is: %q, but should be: %q", got, tt.expectPart) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/socket/cache.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "errors" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | ) 12 | 13 | var ErrExpiredCache error = errors.New("cache file for this source is outdated") 14 | 15 | // hashUrlToFilePath hashes given URL to create cache file path. 16 | func hashUrlToFilePath(url string, cacheDir string) string { 17 | hash := sha1.Sum([]byte(url)) 18 | filename := hex.EncodeToString(hash[:]) + ".json" 19 | return filepath.Join(cacheDir, filename) 20 | } 21 | 22 | // saveSocketsToCache caches socket data to specified file in cache directory. 23 | func saveSocketsToCache(filePath string, cacheDir string, data []byte) error { 24 | // Make sure that cache directory exists 25 | if err := os.MkdirAll(cacheDir, 0o600); err != nil { 26 | return err 27 | } 28 | 29 | return os.WriteFile(filePath, data, 0o600) 30 | } 31 | 32 | // loadCachedSockets checks whether the cache is valid (not expired) and the returns the data stream and ModTime of the cache. 33 | func loadCachedSockets(filePath string, cacheTTL uint) (io.ReadCloser, time.Time, error) { 34 | info, err := os.Stat(filePath) 35 | if err != nil { 36 | return nil, time.Time{}, err 37 | } 38 | 39 | reader, err := os.Open(filePath) 40 | if err != nil { 41 | return nil, time.Time{}, err 42 | } 43 | 44 | cacheTime := info.ModTime() 45 | if time.Since(cacheTime) > time.Duration(cacheTTL)*time.Minute { 46 | return reader, cacheTime, ErrExpiredCache 47 | } 48 | 49 | return reader, cacheTime, nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/dish/main.go: -------------------------------------------------------------------------------- 1 | // Package main implements a simple, one-shot monitoring tool which checks the provided target endpoints or sockets 2 | // and reports the results to the configured channels (if any). 3 | package main 4 | 5 | import ( 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "os" 11 | 12 | "go.vxn.dev/dish/pkg/alert" 13 | "go.vxn.dev/dish/pkg/config" 14 | "go.vxn.dev/dish/pkg/logger" 15 | ) 16 | 17 | func run(fs *flag.FlagSet, args []string, _, stderr io.Writer) int { 18 | cfg, err := config.NewConfig(fs, args) 19 | if err != nil { 20 | // If the error is caused due to no source being provided, print help 21 | if errors.Is(err, config.ErrNoSourceProvided) { 22 | printHelp() 23 | return 1 24 | } 25 | // Otherwise, print the error 26 | fmt.Fprintln(stderr, "error loading config:", err) //nolint:errcheck 27 | return 2 28 | } 29 | 30 | logger := logger.NewConsoleLogger(cfg.Verbose, nil) 31 | logger.Info("dish run: started") 32 | 33 | // Run tests on sockets 34 | res, err := runTests(cfg, logger) 35 | if err != nil { 36 | logger.Error(err) 37 | return 3 38 | } 39 | 40 | // Submit results and alerts 41 | alerter := alert.NewAlerter(logger) 42 | alerter.HandleAlerts(res.messengerText, res.results, res.failedCount, cfg) 43 | 44 | if res.failedCount > 0 { 45 | logger.Warn("dish run: some tests failed:\n", res.messengerText) 46 | return 4 47 | } 48 | 49 | logger.Info("dish run: all tests ok") 50 | return 0 51 | } 52 | 53 | func main() { 54 | os.Exit(run(flag.CommandLine, os.Args[1:], os.Stdout, os.Stderr)) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/alert/alerter.go: -------------------------------------------------------------------------------- 1 | // Package alert provides functionality to handle alert and result submission 2 | // to different text (e.g. Telegram) and machine (e.g. webhooks) integration channels. 3 | package alert 4 | 5 | import ( 6 | "net/http" 7 | 8 | "go.vxn.dev/dish/pkg/config" 9 | "go.vxn.dev/dish/pkg/logger" 10 | ) 11 | 12 | // alerter provides a centralized method of alerting the configured channels with the results of the performed checks 13 | // while hiding implementation details of the channels. 14 | type alerter struct { 15 | logger logger.Logger 16 | } 17 | 18 | // NewAlerter returns a new instance of alerter using the provided logger. 19 | func NewAlerter(l logger.Logger) *alerter { 20 | if l == nil { 21 | return nil 22 | } 23 | 24 | return &alerter{ 25 | logger: l, 26 | } 27 | } 28 | 29 | // HandleAlerts notifies all configured channels with either the provided message (if text channel) or the structured results (if machine channel). 30 | func (a *alerter) HandleAlerts(messengerText string, results *Results, failedCount int, config *config.Config) { 31 | if results == nil || config == nil { 32 | return 33 | } 34 | 35 | notifier := NewNotifier(http.DefaultClient, config, a.logger) 36 | if err := notifier.SendChatNotifications(messengerText, failedCount); err != nil { 37 | a.logger.Errorf("some error(s) encountered when sending chat notifications: \n%v", err) 38 | } 39 | if err := notifier.SendMachineNotifications(results, failedCount); err != nil { 40 | a.logger.Errorf("some error(s) encountered when sending machine notifications: \n%v", err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cmd/dish/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestRun_InvalidFlag(t *testing.T) { 11 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 12 | stderr := &bytes.Buffer{} 13 | 14 | code := run(fs, []string{"-notaflag"}, os.Stdout, stderr) 15 | if code != 2 { 16 | t.Errorf("expected exit code 2, got %d", code) 17 | } 18 | } 19 | 20 | func TestRun_NoArgs(t *testing.T) { 21 | fs := flag.NewFlagSet("no_args", flag.ContinueOnError) 22 | stderr := &bytes.Buffer{} 23 | 24 | code := run(fs, []string{}, os.Stdout, stderr) 25 | if code != 1 { 26 | t.Errorf("expected exit code 1, got %d", code) 27 | } 28 | } 29 | 30 | func TestRun_ValidSockets(t *testing.T) { 31 | fs := flag.NewFlagSet("valid_sockets", flag.ContinueOnError) 32 | stderr := &bytes.Buffer{} 33 | tmpfile := testFile(t, "test_sockets.json", []byte(testSocketsValid)) 34 | 35 | code := run(fs, []string{tmpfile}, os.Stdout, stderr) 36 | if code != 0 { 37 | t.Errorf("expected exit code 0 got %d", code) 38 | } 39 | } 40 | 41 | func TestRun_InvalidSockets(t *testing.T) { 42 | fs := flag.NewFlagSet("invalid_sockets", flag.ContinueOnError) 43 | stderr := &bytes.Buffer{} 44 | tmpfile := testFile(t, "test_sockets.json", []byte(testSocketsSomeInvalid)) 45 | 46 | code := run(fs, []string{tmpfile}, os.Stdout, stderr) 47 | if code != 4 { 48 | t.Errorf("expected exit code 4 got %d", code) 49 | } 50 | } 51 | 52 | func TestRun_InvalidSource(t *testing.T) { 53 | fs := flag.NewFlagSet("invalid_source", flag.ContinueOnError) 54 | stderr := &bytes.Buffer{} 55 | 56 | code := run(fs, []string{""}, os.Stdout, stderr) 57 | if code != 3 { 58 | t.Errorf("expected exit code 3 got %d", code) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/alert/webhook.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | "go.vxn.dev/dish/pkg/logger" 11 | ) 12 | 13 | type webhookSender struct { 14 | httpClient HTTPClient 15 | url string 16 | verbose bool 17 | notifySuccess bool 18 | logger logger.Logger 19 | } 20 | 21 | func NewWebhookSender(httpClient HTTPClient, config *config.Config, logger logger.Logger) (*webhookSender, error) { 22 | parsedURL, err := parseAndValidateURL(config.WebhookURL, nil) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &webhookSender{ 28 | httpClient: httpClient, 29 | url: parsedURL.String(), 30 | verbose: config.Verbose, 31 | notifySuccess: config.MachineNotifySuccess, 32 | logger: logger, 33 | }, nil 34 | } 35 | 36 | func (s *webhookSender) send(m *Results, failedCount int) error { 37 | // If no checks failed and success should not be notified, there is nothing to send 38 | if failedCount == 0 && !s.notifySuccess { 39 | s.logger.Debug("no sockets failed, nothing will be sent to webhook") 40 | 41 | return nil 42 | } 43 | 44 | jsonData, err := json.Marshal(m) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | bodyReader := bytes.NewReader(jsonData) 50 | 51 | s.logger.Debugf("prepared webhook data: %s", string(jsonData)) 52 | 53 | res, err := handleSubmit(s.httpClient, http.MethodPost, s.url, bodyReader) 54 | if err != nil { 55 | return fmt.Errorf("error pushing results to webhook: %w", err) 56 | } 57 | 58 | err = handleRead(res, s.logger) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | s.logger.Info("results pushed to webhook") 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger provides a logging interface and implementations for formatted log output. 2 | package logger 3 | 4 | import "fmt" 5 | 6 | // LogLevel specifies a level from which logs are printed. 7 | type logLevel int32 8 | 9 | const ( 10 | TRACE logLevel = iota 11 | DEBUG 12 | INFO 13 | WARN 14 | ERROR 15 | PANIC 16 | ) 17 | 18 | const logPrefixFormat = "%s[ %s ]%s: " 19 | 20 | var logColors = map[logLevel]string{ 21 | TRACE: "\033[34m", // Blue 22 | DEBUG: "\033[36m", // Cyan 23 | INFO: "\033[32m", // Green 24 | WARN: "\033[33m", // Yellow 25 | ERROR: "\033[31m", // Red 26 | PANIC: "\033[35m", // Magenta 27 | } 28 | 29 | var logLabel = map[logLevel]string{ 30 | TRACE: "TRACE", 31 | DEBUG: "DEBUG", 32 | INFO: "INFO", 33 | WARN: "WARN", 34 | ERROR: "ERROR", 35 | PANIC: "PANIC", 36 | } 37 | 38 | func (l logLevel) Color() string { 39 | if color, exists := logColors[l]; exists { 40 | return color 41 | } 42 | return "\033[0m" // Default color (reset) 43 | } 44 | 45 | func (l logLevel) Prefix(withColor bool) string { 46 | label, labelExists := logLabel[l] 47 | 48 | if !labelExists { 49 | return "[ UNKNOWN ]: " 50 | } 51 | 52 | colorStart, colorReset := "", "" 53 | if withColor { 54 | colorStart = l.Color() 55 | colorReset = "\033[0m" 56 | } 57 | 58 | return fmt.Sprintf(logPrefixFormat, colorStart, label, colorReset) 59 | } 60 | 61 | // Logger interface defines methods for logging at various levels. 62 | type Logger interface { 63 | Trace(v ...any) 64 | Tracef(format string, v ...any) 65 | Debug(v ...any) 66 | Debugf(format string, v ...any) 67 | Info(v ...any) 68 | Infof(format string, v ...any) 69 | Warn(v ...any) 70 | Warnf(format string, v ...any) 71 | Error(v ...any) 72 | Errorf(format string, v ...any) 73 | Panic(v ...any) 74 | Panicf(format string, v ...any) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/dish/runner_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "testing" 7 | 8 | "go.vxn.dev/dish/pkg/socket" 9 | ) 10 | 11 | // compareResults is a custom comparison function to assert the results returned from the fanInChannels function are equal to the expected results 12 | func compareResults(expected, actual []socket.Result) bool { 13 | sort.Slice(expected, func(i, j int) bool { 14 | return expected[i].ResponseCode < expected[j].ResponseCode 15 | }) 16 | sort.Slice(actual, func(i, j int) bool { 17 | return actual[i].ResponseCode < actual[j].ResponseCode 18 | }) 19 | 20 | for i := range expected { 21 | if !reflect.DeepEqual(expected[i].Socket, actual[i].Socket) || 22 | expected[i].Passed != actual[i].Passed || 23 | expected[i].ResponseCode != actual[i].ResponseCode { 24 | return false 25 | } 26 | } 27 | return true 28 | } 29 | 30 | func TestFanInChannels(t *testing.T) { 31 | testChannels := []chan socket.Result{} 32 | 33 | for range 3 { 34 | c := make(chan socket.Result) 35 | testChannels = append(testChannels, c) 36 | } 37 | 38 | go func() { 39 | for i, channel := range testChannels { 40 | channel <- socket.Result{ 41 | Socket: socket.Socket{}, 42 | Passed: true, 43 | ResponseCode: 200 + i, 44 | } 45 | close(channel) 46 | } 47 | }() 48 | 49 | resultingChan := fanInChannels(testChannels...) 50 | actual := []socket.Result{} 51 | for result := range resultingChan { 52 | actual = append(actual, result) 53 | } 54 | 55 | expected := []socket.Result{ 56 | { 57 | Socket: socket.Socket{}, 58 | Passed: true, 59 | ResponseCode: 200, 60 | }, { 61 | Socket: socket.Socket{}, 62 | Passed: true, 63 | ResponseCode: 201, 64 | }, { 65 | Socket: socket.Socket{}, 66 | Passed: true, 67 | ResponseCode: 202, 68 | }, 69 | } 70 | 71 | if !compareResults(expected, actual) { 72 | t.Fatalf("expected: %+v, got: %+v", expected, actual) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/alert/telegram.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "go.vxn.dev/dish/pkg/config" 9 | "go.vxn.dev/dish/pkg/logger" 10 | ) 11 | 12 | const ( 13 | baseURL = "https://api.telegram.org" 14 | telegramMessageTitle = "\U0001F4E1 dish run results:" // 📡 15 | ) 16 | 17 | type telegramSender struct { 18 | httpClient HTTPClient 19 | chatID string 20 | token string 21 | verbose bool 22 | notifySuccess bool 23 | logger logger.Logger 24 | } 25 | 26 | func NewTelegramSender(httpClient HTTPClient, config *config.Config, logger logger.Logger) *telegramSender { 27 | return &telegramSender{ 28 | httpClient: httpClient, 29 | chatID: config.TelegramChatID, 30 | token: config.TelegramBotToken, 31 | verbose: config.Verbose, 32 | notifySuccess: config.TextNotifySuccess, 33 | logger: logger, 34 | } 35 | } 36 | 37 | func (s *telegramSender) send(rawMessage string, failedCount int) error { 38 | // If no checks failed and success should not be notified, there is nothing to send 39 | if failedCount == 0 && !s.notifySuccess { 40 | s.logger.Debug("no sockets failed, nothing will be sent to Telegram") 41 | 42 | return nil 43 | } 44 | 45 | // Construct the Telegram URL with params and the message 46 | telegramURL := fmt.Sprintf("%s/bot%s/sendMessage", baseURL, s.token) 47 | 48 | params := url.Values{} 49 | params.Set("chat_id", s.chatID) 50 | params.Set("disable_web_page_preview", "true") 51 | params.Set("parse_mode", "HTML") 52 | params.Set("text", FormatMessengerTextWithHeader(telegramMessageTitle, rawMessage)) 53 | 54 | fullURL := telegramURL + "?" + params.Encode() 55 | 56 | res, err := handleSubmit(s.httpClient, http.MethodGet, fullURL, nil) 57 | if err != nil { 58 | return fmt.Errorf("error submitting Telegram alert: %w", err) 59 | } 60 | 61 | err = handleRead(res, s.logger) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | s.logger.Info("Telegram alert sent") 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/alert/api.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | "go.vxn.dev/dish/pkg/logger" 11 | ) 12 | 13 | type apiSender struct { 14 | httpClient HTTPClient 15 | url string 16 | headerName string 17 | headerValue string 18 | verbose bool 19 | notifySuccess bool 20 | logger logger.Logger 21 | } 22 | 23 | func NewAPISender(httpClient HTTPClient, config *config.Config, logger logger.Logger) (*apiSender, error) { 24 | parsedURL, err := parseAndValidateURL(config.ApiURL, nil) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &apiSender{ 30 | httpClient: httpClient, 31 | url: parsedURL.String(), 32 | headerName: config.ApiHeaderName, 33 | headerValue: config.ApiHeaderValue, 34 | verbose: config.Verbose, 35 | notifySuccess: config.MachineNotifySuccess, 36 | logger: logger, 37 | }, nil 38 | } 39 | 40 | func (s *apiSender) send(m *Results, failedCount int) error { 41 | // If no checks failed and success should not be notified, there is nothing to send 42 | if failedCount == 0 && !s.notifySuccess { 43 | s.logger.Debug("no sockets failed, nothing will be sent to remote API") 44 | 45 | return nil 46 | } 47 | 48 | jsonData, err := json.Marshal(m) 49 | if err != nil { 50 | return fmt.Errorf("failed to marshal JSON: %w", err) 51 | } 52 | 53 | bodyReader := bytes.NewReader(jsonData) 54 | 55 | s.logger.Debugf("prepared remote API data: %s", string(jsonData)) 56 | 57 | // If custom header & value is provided (mostly used for auth purposes), include it in the request 58 | opts := []func(*submitOptions){} 59 | if s.headerName != "" && s.headerValue != "" { 60 | opts = append(opts, withHeader(s.headerName, s.headerValue)) 61 | } 62 | 63 | res, err := handleSubmit(s.httpClient, http.MethodPost, s.url, bodyReader, opts...) 64 | if err != nil { 65 | return fmt.Errorf("error pushing results to remote API: %w", err) 66 | } 67 | 68 | err = handleRead(res, s.logger) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | s.logger.Info("results pushed to remote API") 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/alert/url_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import "testing" 4 | 5 | func TestParseAndValidateURL(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | url string 9 | supportedSchemes []string 10 | wantErr bool 11 | }{ 12 | { 13 | name: "Empty URL", 14 | url: "", 15 | supportedSchemes: defaultSchemes, 16 | wantErr: true, 17 | }, 18 | { 19 | name: "Invalid URL Format", 20 | url: "::invalid-url", 21 | supportedSchemes: defaultSchemes, 22 | wantErr: true, 23 | }, 24 | { 25 | name: "No Protocol Specified", 26 | url: "//example.com", 27 | supportedSchemes: defaultSchemes, 28 | wantErr: true, 29 | }, 30 | { 31 | name: "Unsupported Protocol", 32 | url: "htp://xyz.testdomain.abcdef", 33 | supportedSchemes: defaultSchemes, 34 | wantErr: true, 35 | }, 36 | { 37 | name: "No Host", 38 | url: "https://", 39 | supportedSchemes: defaultSchemes, 40 | wantErr: true, 41 | }, 42 | { 43 | name: "Valid URL", 44 | url: "https://vxn.dev", 45 | supportedSchemes: defaultSchemes, 46 | wantErr: false, 47 | }, 48 | { 49 | name: "Custom Supported Schemes with Valid URL", 50 | url: "ftp://vxn.dev", 51 | supportedSchemes: []string{"ftp"}, 52 | wantErr: false, 53 | }, 54 | { 55 | name: "Custom Supported Schemes with Invalid URL", 56 | url: "https://vxn.dev", 57 | supportedSchemes: []string{"ftp"}, 58 | wantErr: true, 59 | }, 60 | { 61 | name: "No Supported Schemes Provided (nil)", 62 | url: "https://vxn.dev", 63 | supportedSchemes: nil, 64 | wantErr: false, 65 | }, 66 | } 67 | 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | _, err := parseAndValidateURL(tt.url, tt.supportedSchemes) 71 | 72 | if tt.wantErr && err == nil { 73 | t.Error("expected an error but got none") 74 | } else if !tt.wantErr && err != nil { 75 | t.Errorf("unexpected error: %v", err) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/alert/discord.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "go.vxn.dev/dish/pkg/config" 11 | "go.vxn.dev/dish/pkg/logger" 12 | ) 13 | 14 | const discordMessageTitle = "📡 **dish run results**:" 15 | 16 | type discordSender struct { 17 | botToken string 18 | channelID string 19 | httpClient HTTPClient 20 | logger logger.Logger 21 | notifySuccess bool 22 | url string 23 | } 24 | 25 | type discordMessagePayload struct { 26 | Content string `json:"content"` 27 | Flags int `json:"flags,omitempty"` 28 | } 29 | 30 | const ( 31 | discordBaseURL = "https://discord.com/api/v10" 32 | discordMessagesPath = "/channels/%s/messages" 33 | discordMessagesURL = discordBaseURL + discordMessagesPath 34 | ) 35 | 36 | func NewDiscordSender(httpClient HTTPClient, config *config.Config, logger logger.Logger) (ChatNotifier, error) { 37 | parsedURL, err := parseAndValidateURL(fmt.Sprintf(discordMessagesURL, strings.TrimSpace(config.DiscordChannelID)), nil) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return &discordSender{ 42 | botToken: config.DiscordBotToken, 43 | channelID: config.DiscordChannelID, 44 | httpClient: httpClient, 45 | logger: logger, 46 | notifySuccess: config.TextNotifySuccess, 47 | url: parsedURL.String(), 48 | }, nil 49 | } 50 | 51 | func (s *discordSender) send(message string, failedCount int) error { 52 | // If no checks failed and success should not be notified, there is nothing to send 53 | if failedCount == 0 && !s.notifySuccess { 54 | s.logger.Debug("no sockets failed, nothing will be sent to Telegram") 55 | 56 | return nil 57 | } 58 | 59 | payload := discordMessagePayload{ 60 | Content: FormatMessengerTextWithHeader(discordMessageTitle, message), 61 | Flags: 4, // Suppress embedded links in the message 62 | } 63 | body, err := json.Marshal(payload) 64 | if err != nil { 65 | return fmt.Errorf("error submitting discord alert: %w ", err) 66 | } 67 | 68 | resp, err := handleSubmit(s.httpClient, http.MethodPost, s.url, bytes.NewBuffer(body), func(o *submitOptions) { 69 | o.headers["Authorization"] = "Bot " + strings.TrimSpace(s.botToken) 70 | }) 71 | if err != nil { 72 | return fmt.Errorf("error submitting discord alert: %w", err) 73 | } 74 | 75 | err = handleRead(resp, s.logger) 76 | if err != nil { 77 | return fmt.Errorf("error submitting discord alert: non-success status code: %d", resp.StatusCode) 78 | } 79 | 80 | s.logger.Debug("discord message sent") 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/socket/socket.go: -------------------------------------------------------------------------------- 1 | // Package socket provides functionality related to handling sockets, which is a structure 2 | // representing the target endpoint/socket to be checked. 3 | package socket 4 | 5 | import ( 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | 11 | "go.vxn.dev/dish/pkg/config" 12 | "go.vxn.dev/dish/pkg/logger" 13 | ) 14 | 15 | type Result struct { 16 | Socket Socket 17 | Passed bool 18 | ResponseCode int 19 | Error error 20 | } 21 | 22 | type SocketList struct { 23 | Sockets []Socket `json:"sockets"` 24 | } 25 | 26 | type Socket struct { 27 | // ID is an unique identifier of such socket. 28 | ID string `json:"id"` 29 | 30 | // Socket name, unique identificator, snake_cased. 31 | Name string `json:"socket_name"` 32 | 33 | // Remote endpoint hostname or URL. 34 | Host string `json:"host_name"` 35 | 36 | // Remote port to assemble a socket. 37 | Port int `json:"port_tcp"` 38 | 39 | // HTTP Status Codes expected when giving the endpoint a HEAD/GET request. 40 | ExpectedHTTPCodes []int `json:"expected_http_code_array"` 41 | 42 | // HTTP Path to test on Host. 43 | PathHTTP string `json:"path_http"` 44 | } 45 | 46 | // PrintSockets prints SocketList. 47 | func PrintSockets(list *SocketList, logger logger.Logger) { 48 | logger.Debug("loaded sockets:") 49 | for _, socket := range list.Sockets { 50 | logger.Debugf("Host: %s, Port: %d, ExpectedHTTPCodes: %v", socket.Host, socket.Port, socket.ExpectedHTTPCodes) 51 | } 52 | } 53 | 54 | // LoadSocketList decodes a JSON encoded SocketList from the provided io.ReadCloser. 55 | func LoadSocketList(reader io.ReadCloser) (list *SocketList, err error) { 56 | // defer a closure that appends a Close() error to the returned err 57 | defer func() { 58 | if cerr := reader.Close(); cerr != nil { 59 | cerr = fmt.Errorf("close error: %w", cerr) 60 | err = errors.Join(cerr, err) 61 | } 62 | }() 63 | 64 | list = new(SocketList) 65 | if err = json.NewDecoder(reader).Decode(list); err != nil { 66 | err = fmt.Errorf("error decoding sockets JSON: %w", err) 67 | return nil, err 68 | } 69 | 70 | return list, nil 71 | } 72 | 73 | // FetchSocketList fetches the list of sockets to be checked. 'input' should be a string like '/path/filename.json', or an HTTP URL string. 74 | func FetchSocketList(config *config.Config, logger logger.Logger) (*SocketList, error) { 75 | var reader io.ReadCloser 76 | var err error 77 | 78 | fetchHandler := NewFetchHandler(logger) 79 | if IsFilePath(config.Source) { 80 | reader, err = fetchHandler.fetchSocketsFromFile(config) 81 | } else { 82 | reader, err = fetchHandler.fetchSocketsFromRemote(config) 83 | } 84 | 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return LoadSocketList(reader) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/socket/helpers_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | // This socket list is used across tests. 12 | const testSockets string = `{ "sockets": [ { "id": "vxn_dev_https", "socket_name": "vxn-dev HTTPS", "host_name": "https://vxn.dev", "port_tcp": 443, "path_http": "/", "expected_http_code_array": [200] } ] }` 13 | 14 | // testFile creates a temporary file inside of a temporary directory with the provided filename and data. 15 | // The temporary directory including the file is removed when the test using it finishes. 16 | func testFile(t *testing.T, data []byte) string { 17 | t.Helper() 18 | dir := t.TempDir() 19 | filename := "randomhash.json" 20 | 21 | filepath := filepath.Join(dir, filename) 22 | 23 | err := os.WriteFile(filepath, data, 0o600) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | return filepath 29 | } 30 | 31 | // newMockServer creates an httptest.Server that simulates an expected API endpoint. 32 | // It validates a specific request header (if provided) and returns a customizable response. 33 | func newMockServer(t *testing.T, expectedHeaderName, expectedHeaderValue, responseBody string, statusCode int) *httptest.Server { 34 | t.Helper() 35 | 36 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | if expectedHeaderName != "" && expectedHeaderValue != "" { 38 | if r.Header.Get(expectedHeaderName) != expectedHeaderValue { 39 | http.Error(w, `{"error":"Invalid or missing header"}`, http.StatusForbidden) 40 | return 41 | } 42 | } 43 | 44 | w.Header().Set("Content-Type", "application/json") 45 | w.WriteHeader(statusCode) 46 | _, err := w.Write([]byte(responseBody)) 47 | if err != nil { 48 | t.Fatalf("failed to create new mock server: %v", err) 49 | } 50 | })) 51 | 52 | // Automatically shut down the server when the test completes or fails 53 | t.Cleanup(func() { 54 | server.Close() 55 | }) 56 | 57 | return server 58 | } 59 | 60 | // mockLogger is a mock implementation of the Logger interface with empty method implementations. 61 | type mockLogger struct{} 62 | 63 | func (l *mockLogger) Trace(v ...any) {} 64 | func (l *mockLogger) Tracef(format string, v ...any) {} 65 | func (l *mockLogger) Debug(v ...any) {} 66 | func (l *mockLogger) Debugf(format string, v ...any) {} 67 | func (l *mockLogger) Info(v ...any) {} 68 | func (l *mockLogger) Infof(format string, v ...any) {} 69 | func (l *mockLogger) Warn(v ...any) {} 70 | func (l *mockLogger) Warnf(format string, v ...any) {} 71 | func (l *mockLogger) Error(v ...any) {} 72 | func (l *mockLogger) Errorf(format string, v ...any) {} 73 | func (l *mockLogger) Panic(v ...any) {} 74 | func (l *mockLogger) Panicf(format string, v ...any) {} 75 | -------------------------------------------------------------------------------- /pkg/logger/console_logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | ) 9 | 10 | // consoleLogger logs to the output provided when instantiating it via NewConsoleLogger. 11 | type consoleLogger struct { 12 | stdLogger *log.Logger 13 | logLevel logLevel 14 | withColors bool 15 | } 16 | 17 | var defaultOut = os.Stderr 18 | 19 | // NewConsoleLogger creates a new ConsoleLogger instance logging to the provided output. 20 | // If the output is not specified (nil), it logs to stderr by default. 21 | // 22 | // If verbose is true, log level is set to TRACE (otherwise to INFO). 23 | func NewConsoleLogger(verbose bool, out io.Writer) *consoleLogger { 24 | if out == nil { 25 | out = defaultOut 26 | } 27 | 28 | l := &consoleLogger{ 29 | stdLogger: log.New(out, "", log.LstdFlags), 30 | withColors: os.Getenv("NO_COLOR") != "true" && verbose, 31 | } 32 | 33 | l.logLevel = INFO 34 | if verbose { 35 | l.logLevel = TRACE 36 | } 37 | 38 | return l 39 | } 40 | 41 | // log prints a message if the current log level allows it. 42 | // It adds the passed prefix and formats the output if a format string is passed. 43 | func (l *consoleLogger) log(level logLevel, prefix string, format string, v ...any) { 44 | if l.logLevel > level { 45 | return 46 | } 47 | 48 | msg := prefix + fmt.Sprint(v...) 49 | if format != "" { 50 | msg = prefix + fmt.Sprintf(format, v...) 51 | } 52 | 53 | l.stdLogger.Print(msg) 54 | 55 | if level == PANIC { 56 | panic(msg) 57 | } 58 | } 59 | 60 | func (l *consoleLogger) Trace(v ...any) { 61 | l.log(TRACE, TRACE.Prefix(l.withColors), "", v...) 62 | } 63 | 64 | func (l *consoleLogger) Tracef(f string, v ...any) { 65 | l.log(TRACE, TRACE.Prefix(l.withColors), f, v...) 66 | } 67 | 68 | func (l *consoleLogger) Debug(v ...any) { 69 | l.log(DEBUG, DEBUG.Prefix(l.withColors), "", v...) 70 | } 71 | 72 | func (l *consoleLogger) Debugf(f string, v ...any) { 73 | l.log(DEBUG, DEBUG.Prefix(l.withColors), f, v...) 74 | } 75 | 76 | func (l *consoleLogger) Info(v ...any) { 77 | l.log(INFO, INFO.Prefix(l.withColors), "", v...) 78 | } 79 | 80 | func (l *consoleLogger) Infof(f string, v ...any) { 81 | l.log(INFO, INFO.Prefix(l.withColors), f, v...) 82 | } 83 | 84 | func (l *consoleLogger) Warn(v ...any) { 85 | l.log(WARN, WARN.Prefix(l.withColors), "", v...) 86 | } 87 | 88 | func (l *consoleLogger) Warnf(f string, v ...any) { 89 | l.log(WARN, WARN.Prefix(l.withColors), f, v...) 90 | } 91 | 92 | func (l *consoleLogger) Error(v ...any) { 93 | l.log(ERROR, ERROR.Prefix(l.withColors), "", v...) 94 | } 95 | 96 | func (l *consoleLogger) Errorf(f string, v ...any) { 97 | l.log(ERROR, ERROR.Prefix(l.withColors), f, v...) 98 | } 99 | 100 | func (l *consoleLogger) Panic(v ...any) { 101 | l.log(PANIC, PANIC.Prefix(l.withColors), "", v...) 102 | } 103 | 104 | func (l *consoleLogger) Panicf(f string, v ...any) { 105 | l.log(PANIC, PANIC.Prefix(l.withColors), f, v...) 106 | } 107 | -------------------------------------------------------------------------------- /cmd/dish/runner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "go.vxn.dev/dish/pkg/alert" 8 | "go.vxn.dev/dish/pkg/config" 9 | "go.vxn.dev/dish/pkg/logger" 10 | "go.vxn.dev/dish/pkg/netrunner" 11 | "go.vxn.dev/dish/pkg/socket" 12 | ) 13 | 14 | // testResults holds the overall results of all socket checks combined. 15 | type testResults struct { 16 | messengerText string 17 | results *alert.Results 18 | failedCount int 19 | } 20 | 21 | // fanInChannels collects results from multiple goroutines. 22 | func fanInChannels(channels ...chan socket.Result) <-chan socket.Result { 23 | var wg sync.WaitGroup 24 | out := make(chan socket.Result) 25 | 26 | // Start a goroutine for each channel 27 | for _, channel := range channels { 28 | wg.Add(1) 29 | go func(ch <-chan socket.Result) { 30 | defer wg.Done() 31 | for result := range ch { 32 | // Forward the result to the output channel 33 | out <- result 34 | } 35 | }(channel) 36 | } 37 | 38 | // Close the output channel once all workers are done 39 | go func() { 40 | wg.Wait() 41 | close(out) 42 | }() 43 | 44 | return out 45 | } 46 | 47 | // runTests orchestrates the process of checking of a list of sockets. It fetches the socket list, runs socket checks, collects results and returns them. 48 | func runTests(cfg *config.Config, logger logger.Logger) (*testResults, error) { 49 | // Load socket list to run tests on 50 | list, err := socket.FetchSocketList(cfg, logger) 51 | if err != nil { 52 | return nil, fmt.Errorf("error loading socket list: %w", err) 53 | } 54 | 55 | // Print loaded sockets if flag is set in cfg 56 | if cfg.Verbose { 57 | socket.PrintSockets(list, logger) 58 | } 59 | 60 | testResults := &testResults{ 61 | messengerText: "", 62 | results: &alert.Results{Map: make(map[string]bool)}, 63 | failedCount: 0, 64 | } 65 | 66 | var ( 67 | // A slice of channels needs to be used here so that each goroutine has its own channel which it then closes upon performing the socket check. One shared channel for all goroutines would not work as it would not be clear which goroutine should close the channel. 68 | channels = make([](chan socket.Result), len(list.Sockets)) 69 | 70 | wg sync.WaitGroup 71 | i int 72 | ) 73 | 74 | // Start goroutines for each socket test 75 | for _, sock := range list.Sockets { 76 | wg.Add(1) 77 | channels[i] = make(chan socket.Result) 78 | 79 | go netrunner.RunSocketTest(sock, channels[i], &wg, cfg, logger) 80 | i++ 81 | } 82 | 83 | // Merge channels into one 84 | results := fanInChannels(channels...) 85 | wg.Wait() 86 | 87 | // Collect results 88 | for result := range results { 89 | if !result.Passed || result.Error != nil { 90 | testResults.failedCount++ 91 | } 92 | if !result.Passed || cfg.TextNotifySuccess { 93 | testResults.messengerText += alert.FormatMessengerText(result) 94 | } 95 | testResults.results.Map[result.Socket.ID] = result.Passed 96 | } 97 | 98 | return testResults, nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/alert/transport.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "go.vxn.dev/dish/pkg/logger" 11 | ) 12 | 13 | // submitOptions holds optional parameters for submitting HTTP requests using handleSubmit. 14 | type submitOptions struct { 15 | contentType string 16 | headers map[string]string 17 | } 18 | 19 | // withContentType sets the provided contentType as the value of the Content-Type header. 20 | func withContentType(contentType string) func(*submitOptions) { 21 | return func(opts *submitOptions) { 22 | opts.contentType = contentType 23 | } 24 | } 25 | 26 | // withHeader adds the provided key:value header pair to the request's HTTP headers. 27 | func withHeader(key string, value string) func(*submitOptions) { 28 | return func(opts *submitOptions) { 29 | if opts.headers == nil { 30 | opts.headers = make(map[string]string) 31 | } 32 | opts.headers[key] = value 33 | } 34 | } 35 | 36 | // handleSubmit submits an HTTP request using the provided client and method to the specified url with the provided body (can be nil if no body is required) and returns the response. 37 | // 38 | // By default, the application/json Content-Type header is used. A different content type can be specified using the withContentType functional option. 39 | // Custom header key:value pairs can be specified using the withHeader functional option. 40 | func handleSubmit(client HTTPClient, method string, url string, body io.Reader, opts ...func(*submitOptions)) (*http.Response, error) { 41 | // Default options 42 | options := submitOptions{ 43 | contentType: "application/json", 44 | headers: make(map[string]string), 45 | } 46 | 47 | // Apply provided options, if any, to the defaults 48 | for _, opt := range opts { 49 | opt(&options) 50 | } 51 | 52 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 53 | defer cancel() 54 | 55 | req, err := http.NewRequestWithContext(ctx, method, url, body) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | req.Header.Set("Content-Type", options.contentType) 61 | 62 | // Apply provided custom headers, if any 63 | for k, v := range options.headers { 64 | req.Header.Set(k, v) 65 | } 66 | 67 | res, err := client.Do(req) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return res, nil 73 | } 74 | 75 | // handleRead reads an HTTP response, ensures the status code is within the expected <200, 299> range and if not, logs the response body. 76 | func handleRead(res *http.Response, logger logger.Logger) error { 77 | defer func() { 78 | if err := res.Body.Close(); err != nil { 79 | logger.Errorf("failed to close response body: %v", err) 80 | } 81 | }() 82 | 83 | if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { 84 | body, err := io.ReadAll(res.Body) 85 | if err != nil { 86 | logger.Errorf("error reading response body: %v", err) 87 | } else { 88 | logger.Warnf("response from %s: %s", res.Request.URL, string(body)) 89 | } 90 | 91 | return fmt.Errorf("unexpected response code received (expected: 200-299, got: %d)", res.StatusCode) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /pkg/socket/socket_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "reflect" 8 | "testing" 9 | 10 | "go.vxn.dev/dish/pkg/config" 11 | "go.vxn.dev/dish/pkg/logger" 12 | ) 13 | 14 | func TestPrintSockets(t *testing.T) { 15 | list := &SocketList{ 16 | Sockets: []Socket{ 17 | {ID: "1", Name: "socket", Host: "example.com", Port: 80, ExpectedHTTPCodes: []int{200, 404}}, 18 | }, 19 | } 20 | 21 | var buf bytes.Buffer 22 | logger := logger.NewConsoleLogger(true, &buf) 23 | 24 | PrintSockets(list, logger) 25 | 26 | expected := "Host: example.com, Port: 80, ExpectedHTTPCodes: [200 404]\n" 27 | if !bytes.Contains(buf.Bytes(), []byte(expected)) { 28 | t.Errorf("Expected TestPrintSockets() to contain %s, but got %s", expected, buf.String()) 29 | } 30 | } 31 | 32 | func TestLoadSocketList(t *testing.T) { 33 | tests := []struct { 34 | name string 35 | json string 36 | expectErr bool 37 | }{ 38 | { 39 | "Valid JSON", 40 | testSockets, 41 | false, 42 | }, 43 | { 44 | "Invalid JSON", 45 | `{ "sockets": [ { "id": "vxn_dev_https"`, 46 | true, 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | reader := io.NopCloser(bytes.NewReader([]byte(tt.json))) 53 | if _, err := LoadSocketList(reader); (err == nil) == tt.expectErr { 54 | t.Errorf("Expect error: %v, got error: %v\n", tt.expectErr, err) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestFetchSocketList(t *testing.T) { 61 | mockServer := newMockServer(t, "", "", testSockets, http.StatusOK) 62 | validFile := testFile(t, []byte(testSockets)) 63 | socketStringReader := io.NopCloser(bytes.NewBufferString(testSockets)) 64 | originalList, err := LoadSocketList(socketStringReader) 65 | if err != nil { 66 | t.Fatalf("failed to parse sockets string to an object: %v", err) 67 | } 68 | 69 | newConfig := func(source string) *config.Config { 70 | return &config.Config{ 71 | Source: source, 72 | } 73 | } 74 | 75 | tests := []struct { 76 | name string 77 | source string 78 | expectError bool 79 | }{ 80 | { 81 | name: "Fetch from file", 82 | source: validFile, 83 | expectError: false, 84 | }, 85 | { 86 | name: "Fetch from remote", 87 | source: mockServer.URL, 88 | expectError: false, 89 | }, 90 | { 91 | name: "Fetch from remote with bad URL", 92 | source: "http://invalid-host.local", 93 | expectError: true, 94 | }, 95 | { 96 | name: "Fetch from not existent file", 97 | source: "thisdoesntexist.json", 98 | expectError: true, 99 | }, 100 | } 101 | 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | cfg := newConfig(tt.source) 105 | 106 | fetchedList, err := FetchSocketList(cfg, &mockLogger{}) 107 | if tt.expectError { 108 | if err == nil { 109 | t.Errorf("expected error, got %v", err) 110 | } 111 | return 112 | } 113 | 114 | if err != nil { 115 | t.Fatalf("expected no error, got %v", err) 116 | } 117 | 118 | // Manual comparison of 2 objects won't work because of expected codes type ([]int) in Socket struct 119 | if !reflect.DeepEqual(fetchedList, originalList) { 120 | t.Errorf("expected %+v, got %+v", originalList, fetchedList) 121 | } 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/socket/cache_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestHashUrlToFilePath(t *testing.T) { 13 | tests := []struct { 14 | url string 15 | cacheDir string 16 | expected string 17 | }{ 18 | { 19 | "https://example.com", 20 | "test_cache", 21 | filepath.Join("test_cache", "327c3fda87ce286848a574982ddd0b7c7487f816.json"), 22 | }, 23 | { 24 | "http://localhost", 25 | "test_cache", 26 | filepath.Join("test_cache", "8523ab8065a69338d5006c34310dc8d2c0179ebb.json"), 27 | }, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.url, func(t *testing.T) { 32 | got := hashUrlToFilePath(tt.url, tt.cacheDir) 33 | if got != tt.expected { 34 | t.Errorf("got %s, want %s", got, tt.expected) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func TestSaveSocketsToCache(t *testing.T) { 41 | filePath := testFile(t, nil) 42 | cacheDir := filepath.Dir(filePath) 43 | 44 | if err := saveSocketsToCache(filePath, cacheDir, []byte(testSockets)); err != nil { 45 | t.Fatalf("expected no error, but got %v", err) 46 | } 47 | 48 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 49 | t.Fatalf("expected file %s to exist, but it does not", filePath) 50 | } 51 | 52 | readBytes, err := os.ReadFile(filePath) 53 | if err != nil { 54 | t.Fatalf("failed to read saved cache: %v", err) 55 | } 56 | 57 | if string(readBytes) != testSockets { 58 | t.Errorf("expected file content %s, got %s", testSockets, string(readBytes)) 59 | } 60 | } 61 | 62 | func TestLoadSocketsFromCache(t *testing.T) { 63 | t.Run("Load Sockets From Cache", func(t *testing.T) { 64 | filePath := testFile(t, []byte(testSockets)) 65 | cacheTTL := uint(60) 66 | 67 | readerFromCache, _, err := loadCachedSockets(filePath, cacheTTL) 68 | if err != nil { 69 | t.Fatalf("expected no error, but got %v", err) 70 | } 71 | defer func() { 72 | if cerr := readerFromCache.Close(); cerr != nil { 73 | t.Fatalf("failed to close cache reader: %v", cerr) 74 | } 75 | }() 76 | 77 | readBytes, err := io.ReadAll(readerFromCache) 78 | if err != nil { 79 | t.Fatalf("failed to read saved cache: %v", err) 80 | } 81 | 82 | if string(readBytes) != testSockets { 83 | t.Errorf("expected retrieved data to be %s, got %s", testSockets, string(readBytes)) 84 | } 85 | }) 86 | 87 | t.Run("Load Sockets From Expired Cache", func(t *testing.T) { 88 | filePath := testFile(t, []byte(testSockets)) 89 | cacheTTL := uint(0) 90 | 91 | // For some reason Windows tests in CI/CD think that 0 time has elapsed since the creation of the test file when it's being checked inside of loadCachedSockets, therefore the expired cache error is not returned. 92 | // Sleeping for a couple ms seems to have solved the issue. 93 | time.Sleep(200 * time.Millisecond) 94 | 95 | readerFromCache, _, err := loadCachedSockets(filePath, cacheTTL) 96 | if !errors.Is(err, ErrExpiredCache) { 97 | t.Errorf("expected error %v, but got %v", ErrExpiredCache, err) 98 | } 99 | 100 | defer func() { 101 | if cerr := readerFromCache.Close(); cerr != nil { 102 | t.Fatalf("failed to close cache reader: %v", cerr) 103 | } 104 | }() 105 | 106 | readBytes, err := io.ReadAll(readerFromCache) 107 | if err != nil { 108 | t.Fatalf("failed to read saved cache: %v", err) 109 | } 110 | 111 | if string(readBytes) != testSockets { 112 | t.Errorf("expected retrieved data to be %s, got %s", testSockets, string(readBytes)) 113 | } 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /pkg/socket/fetch_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | 11 | "go.vxn.dev/dish/pkg/config" 12 | ) 13 | 14 | func TestNewFetchHandler(t *testing.T) { 15 | expected := &fetchHandler{ 16 | logger: &mockLogger{}, 17 | } 18 | actual := NewFetchHandler(&mockLogger{}) 19 | 20 | if !reflect.DeepEqual(expected, actual) { 21 | t.Fatalf("expected %v, got %v", expected, actual) 22 | } 23 | } 24 | 25 | func TestFetchSocketsFromFile(t *testing.T) { 26 | filePath := testFile(t, []byte(testSockets)) 27 | cfg := &config.Config{ 28 | Source: filePath, 29 | } 30 | 31 | fetchHandler := NewFetchHandler(&mockLogger{}) 32 | 33 | reader, err := fetchHandler.fetchSocketsFromFile(cfg) 34 | if err != nil { 35 | t.Fatalf("Failed to fetch sockets from file %v\n", err) 36 | } 37 | 38 | defer func() { 39 | if cerr := reader.Close(); cerr != nil { 40 | t.Fatalf("failed to close file reader: %v", cerr) 41 | } 42 | }() 43 | 44 | fileData, err := io.ReadAll(reader) 45 | if err != nil { 46 | t.Fatalf("Failed to load data from file %v\n", err) 47 | } 48 | 49 | fileDataString := string(fileData) 50 | if fileDataString != testSockets { 51 | t.Errorf("Got %s, expected %s from file\n", fileDataString, testSockets) 52 | } 53 | } 54 | 55 | func TestFetchSocketsFromRemote(t *testing.T) { 56 | apiHeaderName := "Authorization" 57 | apiHeaderValue := "Bearer xyzzzzzzz" 58 | mockServer := newMockServer(t, apiHeaderName, apiHeaderValue, testSockets, http.StatusOK) 59 | 60 | newConfig := func(source string, useCache bool, ttl uint) *config.Config { 61 | // Temp cache directory needs to be created and specified for each test separately 62 | // See the range tests below 63 | return &config.Config{ 64 | Source: source, 65 | ApiCacheSockets: useCache, 66 | ApiCacheTTLMinutes: ttl, 67 | ApiHeaderName: apiHeaderName, 68 | ApiHeaderValue: apiHeaderValue, 69 | } 70 | } 71 | 72 | tests := []struct { 73 | name string 74 | cfg *config.Config 75 | expectedError bool 76 | }{ 77 | {"Fetch With Valid Cache", newConfig(mockServer.URL, true, 10), false}, 78 | {"Fetch With Expired Cache", newConfig(mockServer.URL, true, 0), false}, 79 | {"Fetch Without Caching", newConfig(mockServer.URL, false, 0), false}, 80 | {"Invalid URL Without Cache", newConfig("http://badurl.com", false, 0), true}, 81 | {"Invalid URL With Cache", newConfig("http://badurl.com", true, 0), true}, 82 | } 83 | 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | // Specify temp cache file & directory for each test separately 87 | // This fixes open file handles preventing the tests from succeeding on Windows 88 | filePath := testFile(t, []byte(testSockets)) 89 | tt.cfg.ApiCacheDirectory = filepath.Dir(filePath) 90 | 91 | fetchHandler := NewFetchHandler(&mockLogger{}) 92 | 93 | resp, err := fetchHandler.fetchSocketsFromRemote(tt.cfg) 94 | if tt.expectedError { 95 | if err == nil || errors.Is(err, ErrExpiredCache) { 96 | t.Errorf("expected error, got %v", err) 97 | } 98 | return 99 | } 100 | if err != nil { 101 | t.Fatalf("expected no error, got %v", err) 102 | } 103 | 104 | readBytes, err := io.ReadAll(resp) 105 | if err != nil { 106 | t.Fatalf("failed to read from response: %v", err) 107 | } 108 | 109 | if string(readBytes) != testSockets { 110 | t.Errorf("expected %s, got %s", testSockets, string(readBytes)) 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/alert/pushgateway.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "text/template" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | "go.vxn.dev/dish/pkg/logger" 11 | ) 12 | 13 | const ( 14 | // jobName is the name of the Prometheus job used for dish results 15 | jobName = "dish_results" 16 | ) 17 | 18 | // messageTemplate is a template used for the Pushgateway message generated by the createMessage method. 19 | var messageTemplate = ` 20 | #HELP failed sockets registered by dish 21 | #TYPE dish_failed_count counter 22 | dish_failed_count {{ .FailedCount }} 23 | 24 | ` 25 | 26 | // messageData is a struct used to store Pushgateway message template variables. 27 | type messageData struct { 28 | FailedCount int 29 | } 30 | 31 | type pushgatewaySender struct { 32 | httpClient HTTPClient 33 | url string 34 | instanceName string 35 | verbose bool 36 | notifySuccess bool 37 | tmpl *template.Template 38 | logger logger.Logger 39 | } 40 | 41 | // NewPushgatewaySender validates the provided URL, prepares and parses a message template to be used for alerting and returns a new pushgatewaySender struct with the provided attributes. 42 | func NewPushgatewaySender(httpClient HTTPClient, config *config.Config, logger logger.Logger) (*pushgatewaySender, error) { 43 | parsedURL, err := parseAndValidateURL(config.PushgatewayURL, nil) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Prepare and parse the message template to be used when pushing results 49 | tmpl, err := template.New("pushgatewayMessage").Parse(messageTemplate) 50 | if err != nil { 51 | return nil, fmt.Errorf("error creating Pushgateway message template: %w", err) 52 | } 53 | 54 | return &pushgatewaySender{ 55 | httpClient: httpClient, 56 | url: parsedURL.String(), 57 | instanceName: config.InstanceName, 58 | verbose: config.Verbose, 59 | notifySuccess: config.MachineNotifySuccess, 60 | tmpl: tmpl, 61 | logger: logger, 62 | }, nil 63 | } 64 | 65 | // createMessage returns a string containing the message text in Pushgateway-specific format. 66 | func (s *pushgatewaySender) createMessage(failedCount int) (string, error) { 67 | var buf bytes.Buffer 68 | 69 | err := s.tmpl.Execute(&buf, messageData{FailedCount: failedCount}) 70 | if err != nil { 71 | return "", fmt.Errorf("error executing Pushgateway message template: %w", err) 72 | } 73 | 74 | return buf.String(), nil 75 | } 76 | 77 | // Send pushes the results to Pushgateway. 78 | // 79 | // The first argument is needed to implement the MachineNotifier interface, however, it is ignored in favor of a custom message implementation via the createMessage method. 80 | func (s *pushgatewaySender) send(_ *Results, failedCount int) error { 81 | // If no checks failed and success should not be notified, there is nothing to send 82 | if failedCount == 0 && !s.notifySuccess { 83 | s.logger.Debug("no sockets failed, nothing will be sent to Pushgateway") 84 | 85 | return nil 86 | } 87 | 88 | msg, err := s.createMessage(failedCount) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | bodyReader := bytes.NewReader([]byte(msg)) 94 | 95 | formattedURL := s.url + "/metrics/job/" + jobName + "/instance/" + s.instanceName 96 | 97 | res, err := handleSubmit(s.httpClient, http.MethodPut, formattedURL, bodyReader, withContentType("application/byte")) 98 | if err != nil { 99 | return fmt.Errorf("error pushing results to Pushgateway: %w", err) 100 | } 101 | 102 | err = handleRead(res, s.logger) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | s.logger.Info("results pushed to Pushgateway") 108 | 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestNewConfig_DefaultsAndSource(t *testing.T) { 10 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 11 | args := []string{"source.json"} 12 | 13 | expected := &Config{ 14 | InstanceName: defaultInstanceName, 15 | ApiHeaderName: defaultApiHeaderName, 16 | ApiHeaderValue: defaultApiHeaderValue, 17 | ApiCacheSockets: defaultApiCacheSockets, 18 | ApiCacheDirectory: defaultApiCacheDir, 19 | ApiCacheTTLMinutes: defaultApiCacheTTLMinutes, 20 | Source: "source.json", 21 | Verbose: defaultVerbose, 22 | PushgatewayURL: defaultPushgatewayURL, 23 | TelegramBotToken: defaultTelegramBotToken, 24 | TelegramChatID: defaultTelegramChatID, 25 | TimeoutSeconds: defaultTimeoutSeconds, 26 | ApiURL: defaultApiURL, 27 | WebhookURL: defaultWebhookURL, 28 | TextNotifySuccess: defaultTextNotifySuccess, 29 | MachineNotifySuccess: defaultMachineNotifySuccess, 30 | } 31 | 32 | if blank, err := NewConfig(nil, []string{}); err == nil || blank != nil { 33 | t.Fatalf("unexpected behaviour, err should not be nil, output should be nil") 34 | } 35 | 36 | actual, err := NewConfig(fs, args) 37 | if err != nil { 38 | t.Fatalf("unexpected error: %v", err) 39 | } 40 | 41 | if !reflect.DeepEqual(expected, actual) { 42 | t.Errorf("expected %v, got %v", expected, actual) 43 | } 44 | } 45 | 46 | func TestNewConfig_FlagsOverrideDefaults(t *testing.T) { 47 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 48 | args := []string{ 49 | "-name", "custom-dish", 50 | "-timeout", "42", 51 | "-verbose", 52 | "-hname", "X-Auth", 53 | "-hvalue", "secret", 54 | "-cache", 55 | "-cacheDir", "/tmp/cache", 56 | "-cacheTTL", "99", 57 | "-target", "http://push", 58 | "-telegramBotToken", "token", 59 | "-telegramChatID", "chatid", 60 | "-updateURL", "http://api", 61 | "-webhookURL", "http://webhook", 62 | "-textNotifySuccess", 63 | "-machineNotifySuccess", 64 | "mysource.json", 65 | } 66 | 67 | expected := &Config{ 68 | InstanceName: "custom-dish", 69 | TimeoutSeconds: 42, 70 | Verbose: true, 71 | ApiHeaderName: "X-Auth", 72 | ApiHeaderValue: "secret", 73 | ApiCacheSockets: true, 74 | ApiCacheDirectory: "/tmp/cache", 75 | ApiCacheTTLMinutes: 99, 76 | PushgatewayURL: "http://push", 77 | TelegramBotToken: "token", 78 | TelegramChatID: "chatid", 79 | ApiURL: "http://api", 80 | WebhookURL: "http://webhook", 81 | TextNotifySuccess: true, 82 | MachineNotifySuccess: true, 83 | Source: "mysource.json", 84 | } 85 | 86 | actual, err := NewConfig(fs, args) 87 | if err != nil { 88 | t.Fatalf("unexpected error: %v", err) 89 | } 90 | 91 | if !reflect.DeepEqual(expected, actual) { 92 | t.Errorf("expected %v, got %v", expected, actual) 93 | } 94 | } 95 | 96 | func TestNewConfig_NoSourceProvided(t *testing.T) { 97 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 98 | args := []string{} 99 | 100 | _, err := NewConfig(fs, args) 101 | if err == nil { 102 | t.Fatal("expected error for no source provided, got nil") 103 | } 104 | if err != ErrNoSourceProvided { 105 | t.Fatalf("expected ErrNoSourceProvided, got %v", err) 106 | } 107 | } 108 | 109 | func TestNewConfig_InvalidFlag(t *testing.T) { 110 | fs := flag.NewFlagSet("test", flag.ContinueOnError) 111 | args := []string{"-notaflag", "source.json"} 112 | 113 | _, err := NewConfig(fs, args) 114 | if err == nil { 115 | t.Fatal("expected error, got nil") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/alert/formatter_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "go.vxn.dev/dish/pkg/socket" 9 | ) 10 | 11 | func TestFormatMessengerText(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | result socket.Result 15 | expectedText string 16 | }{ 17 | { 18 | name: "Passed TCP Check", 19 | result: socket.Result{ 20 | Socket: socket.Socket{ 21 | ID: "test_socket", 22 | Name: "test socket", 23 | Host: "192.168.0.1", 24 | Port: 123, 25 | }, 26 | Passed: true, 27 | Error: nil, 28 | }, 29 | expectedText: "• 192.168.0.1:123 -- success ✅\n", 30 | }, 31 | { 32 | name: "Passed HTTP Check", 33 | result: socket.Result{ 34 | Socket: socket.Socket{ 35 | ID: "test_socket", 36 | Name: "test socket", 37 | Host: "https://test.testdomain.xyz", 38 | Port: 80, 39 | ExpectedHTTPCodes: []int{200}, 40 | PathHTTP: "/", 41 | }, 42 | Passed: true, 43 | Error: nil, 44 | }, 45 | expectedText: "• https://test.testdomain.xyz:80/ -- success ✅\n", 46 | }, 47 | { 48 | name: "Failed TCP Check", 49 | result: socket.Result{ 50 | Socket: socket.Socket{ 51 | ID: "test_socket", 52 | Name: "test socket", 53 | Host: "192.168.0.1", 54 | Port: 123, 55 | }, 56 | Passed: false, 57 | Error: errors.New("error message"), 58 | }, 59 | expectedText: "• 192.168.0.1:123 -- failed ❌ -- error message\n", 60 | }, 61 | { 62 | name: "Failed HTTP Check with Error", 63 | result: socket.Result{ 64 | Socket: socket.Socket{ 65 | ID: "test_socket", 66 | Name: "test socket", 67 | Host: "https://test.testdomain.xyz", 68 | Port: 80, 69 | ExpectedHTTPCodes: []int{200}, 70 | PathHTTP: "/", 71 | }, 72 | Passed: false, 73 | Error: errors.New("error message"), 74 | }, 75 | expectedText: "• https://test.testdomain.xyz:80/ -- failed ❌ -- error message\n", 76 | }, 77 | { 78 | name: "Failed HTTP Check with Unexpected Response Code", 79 | result: socket.Result{ 80 | Socket: socket.Socket{ 81 | ID: "test_socket", 82 | Name: "test socket", 83 | Host: "https://test.testdomain.xyz", 84 | Port: 80, 85 | ExpectedHTTPCodes: []int{200}, 86 | PathHTTP: "/", 87 | }, 88 | ResponseCode: 500, 89 | Passed: false, 90 | Error: fmt.Errorf("expected codes: %v, got %d", []int{200}, 500), 91 | }, 92 | expectedText: "• https://test.testdomain.xyz:80/ -- failed ❌ -- expected codes: [200], got 500\n", 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | actualText := FormatMessengerText(tt.result) 99 | 100 | if actualText != tt.expectedText { 101 | t.Errorf("expected %s, got %s", tt.expectedText, actualText) 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func TestFormatMessengerTextWithHeader(t *testing.T) { 108 | tests := []struct { 109 | name string 110 | header string 111 | body string 112 | expectedText string 113 | }{ 114 | { 115 | name: "Header with body formatted with 2 newlines", 116 | header: "Test Header", 117 | body: "Test Body", 118 | expectedText: "Test Header\n\nTest Body", 119 | }, 120 | } 121 | 122 | for _, tt := range tests { 123 | t.Run(tt.name, func(t *testing.T) { 124 | actualText := FormatMessengerTextWithHeader(tt.header, tt.body) 125 | if actualText != tt.expectedText { 126 | t.Errorf("expected %s, got %s", tt.expectedText, actualText) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#standard-github-hosted-runners-for-public-repositories 2 | 3 | name: Tests 4 | 5 | on: 6 | push: 7 | branches: ["master"] 8 | pull_request: 9 | branches: ["master"] 10 | workflow_dispatch: 11 | 12 | jobs: 13 | golangci: 14 | strategy: 15 | matrix: 16 | go: [stable] 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | name: lint 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: actions/setup-go@v6 23 | with: 24 | go-version: ${{ matrix.go }} 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v8 27 | with: 28 | version: v2.1 29 | 30 | linux_x64: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up Go 35 | uses: actions/setup-go@v5 36 | with: 37 | go-version: "1.24" 38 | - name: Build 39 | run: go build -v ./cmd/... 40 | - name: Test 41 | # Github Actions cloud runners block any incoming ICMP packets. 42 | # ICMP on Linux is tested on the self-hosted cloud runner. 43 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 44 | 45 | windows_x64: 46 | runs-on: windows-2025 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Set up Go 50 | uses: actions/setup-go@v5 51 | with: 52 | go-version: "1.24" 53 | - name: Build 54 | run: go build -v ./cmd/... 55 | - name: Test 56 | # dish does not support ICMP on Windows at the moment 57 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 58 | 59 | linux_arm64: 60 | runs-on: ubuntu-24.04-arm 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: Set up Go 64 | uses: actions/setup-go@v5 65 | with: 66 | go-version: "1.24" 67 | - name: Build 68 | run: go build -v ./cmd/... 69 | - name: Test 70 | # Github Actions cloud runners block any incoming ICMP packets. 71 | # ICMP on Linux is tested on the self-hosted cloud runner. 72 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 73 | 74 | # windows_arm64: 75 | # runs-on: windows-11-arm 76 | # steps: 77 | # - uses: actions/checkout@v4 78 | # - name: Set up Go 79 | # uses: actions/setup-go@v4 80 | # with: 81 | # go-version: '1.24' 82 | # - name: Build 83 | # run: go build -v ./cmd/... 84 | # - name: Test 85 | # Github Actions cloud runners block any incoming ICMP packets. 86 | # run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 87 | 88 | macOS_intel: 89 | runs-on: macos-13 90 | steps: 91 | - uses: actions/checkout@v4 92 | - name: Set up Go 93 | uses: actions/setup-go@v5 94 | with: 95 | go-version: "1.24" 96 | - name: Build 97 | run: go build -v ./cmd/... 98 | - name: Test 99 | # On macOS cloud runners we test ICMP against the loopback address only. 100 | run: go test -v ./... 101 | 102 | macOS_arm64: 103 | runs-on: macos-latest 104 | steps: 105 | - uses: actions/checkout@v4 106 | - name: Set up Go 107 | uses: actions/setup-go@v5 108 | with: 109 | go-version: "1.24" 110 | - name: Build 111 | run: go build -v ./cmd/... 112 | - name: Test 113 | # On macOS cloud runners we test ICMP against the loopback address only. 114 | run: go test -v ./... 115 | 116 | # Self-hosted runner mainly intended for running ICMP integration tests which cannot be run in the GH runners due to incoming traffic being blocked 117 | self-hosted: 118 | runs-on: ${{ vars.TEST_RUNNER_LABEL }} 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Run tests in a container 122 | env: 123 | PROJECT_NAME: dish 124 | run: make docker-test 125 | -------------------------------------------------------------------------------- /pkg/alert/telegram_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | ) 9 | 10 | func TestNewTelegramSender(t *testing.T) { 11 | mockHTTPClient := &SuccessStatusHTTPClient{} 12 | mockLogger := &MockLogger{} 13 | 14 | token := "abc1234" 15 | chatID := "-123" 16 | verbose := false 17 | notifySuccess := false 18 | 19 | expected := &telegramSender{ 20 | httpClient: mockHTTPClient, 21 | chatID: chatID, 22 | token: token, 23 | verbose: verbose, 24 | notifySuccess: notifySuccess, 25 | logger: mockLogger, 26 | } 27 | 28 | cfg := &config.Config{ 29 | TelegramChatID: chatID, 30 | TelegramBotToken: token, 31 | Verbose: verbose, 32 | TextNotifySuccess: notifySuccess, 33 | } 34 | 35 | actual := NewTelegramSender(mockHTTPClient, cfg, mockLogger) 36 | 37 | if !reflect.DeepEqual(expected, actual) { 38 | t.Fatalf("expected %v, got %v", expected, actual) 39 | } 40 | } 41 | 42 | func TestSend_Telegram(t *testing.T) { 43 | newConfig := func(chatID, token string, verbose, notifySuccess bool) *config.Config { 44 | return &config.Config{ 45 | TelegramChatID: chatID, 46 | TelegramBotToken: token, 47 | Verbose: verbose, 48 | TextNotifySuccess: notifySuccess, 49 | } 50 | } 51 | 52 | tests := []struct { 53 | name string 54 | client HTTPClient 55 | rawMessage string 56 | failedCount int 57 | notifySuccess bool 58 | verbose bool 59 | wantErr bool 60 | }{ 61 | { 62 | name: "Failed Sockets", 63 | client: &SuccessStatusHTTPClient{}, 64 | rawMessage: "Test message", 65 | failedCount: 1, 66 | notifySuccess: false, 67 | verbose: false, 68 | wantErr: false, 69 | }, 70 | { 71 | name: "Failed Sockets - Verbose", 72 | client: &SuccessStatusHTTPClient{}, 73 | rawMessage: "Test message", 74 | failedCount: 1, 75 | notifySuccess: false, 76 | verbose: true, 77 | wantErr: false, 78 | }, 79 | { 80 | name: "No Failed Sockets with notifySuccess", 81 | client: &SuccessStatusHTTPClient{}, 82 | rawMessage: "Test message", 83 | failedCount: 0, 84 | notifySuccess: true, 85 | verbose: false, 86 | wantErr: false, 87 | }, 88 | { 89 | name: "No Failed Sockets without notifySuccess", 90 | client: &SuccessStatusHTTPClient{}, 91 | rawMessage: "Test message", 92 | failedCount: 0, 93 | notifySuccess: false, 94 | verbose: false, 95 | wantErr: false, 96 | }, 97 | { 98 | name: "No Failed Sockets without notifySuccess - Verbose", 99 | client: &SuccessStatusHTTPClient{}, 100 | rawMessage: "Test message", 101 | failedCount: 0, 102 | notifySuccess: false, 103 | verbose: true, 104 | wantErr: false, 105 | }, 106 | { 107 | name: "Network Error When Sending Telegram Message", 108 | client: &FailureHTTPClient{}, 109 | rawMessage: "Test message", 110 | failedCount: 1, 111 | notifySuccess: false, 112 | verbose: false, 113 | wantErr: true, 114 | }, 115 | { 116 | name: "Unexpected Response Code From Telegram", 117 | client: &ErrorStatusHTTPClient{}, 118 | rawMessage: "Test message", 119 | failedCount: 1, 120 | notifySuccess: false, 121 | verbose: false, 122 | wantErr: true, 123 | }, 124 | { 125 | name: "Error Reading Response Body From Telegram", 126 | client: &InvalidResponseBodyHTTPClient{}, 127 | rawMessage: "Test message", 128 | failedCount: 1, 129 | notifySuccess: false, 130 | verbose: true, 131 | wantErr: true, 132 | }, 133 | } 134 | 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | cfg := newConfig("-123", "abc123", tt.verbose, tt.notifySuccess) 138 | sender := NewTelegramSender(tt.client, cfg, &MockLogger{}) 139 | 140 | err := sender.send(tt.rawMessage, tt.failedCount) 141 | 142 | if tt.wantErr != (err != nil) { 143 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 144 | } 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/alert/notifier.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | 8 | "go.vxn.dev/dish/pkg/config" 9 | "go.vxn.dev/dish/pkg/logger" 10 | ) 11 | 12 | type Results struct { 13 | Map map[string]bool `json:"dish_results"` 14 | } 15 | 16 | type ChatNotifier interface { 17 | send(string, int) error 18 | } 19 | 20 | type MachineNotifier interface { 21 | send(*Results, int) error 22 | } 23 | 24 | type notifier struct { 25 | verbose bool 26 | chatNotifiers []ChatNotifier 27 | machineNotifiers []MachineNotifier 28 | logger logger.Logger 29 | } 30 | 31 | type HTTPClient interface { 32 | Do(req *http.Request) (*http.Response, error) 33 | Get(url string) (*http.Response, error) 34 | Post(url string, contentType string, body io.Reader) (*http.Response, error) 35 | } 36 | 37 | // NewNotifier creates a new instance of notifier. Based on the flags used, it spawns new instances of ChatNotifiers (e.g. Telegram) and MachineNotifiers (e.g. Webhooks) and stores them on the notifier struct to be used for alert notifications. 38 | func NewNotifier(httpClient HTTPClient, config *config.Config, logger logger.Logger) *notifier { 39 | if logger == nil { 40 | return nil 41 | } 42 | 43 | if config == nil { 44 | logger.Error("nil pointer to config") 45 | return nil 46 | } 47 | 48 | // Set chat integrations to be notified (e.g. Telegram) 49 | notificationSenders := make([]ChatNotifier, 0) 50 | 51 | // Telegram 52 | if config.TelegramBotToken != "" && config.TelegramChatID != "" { 53 | notificationSenders = append(notificationSenders, NewTelegramSender(httpClient, config, logger)) 54 | } 55 | 56 | // Set machine interface integrations to be notified (e.g. Webhooks) 57 | payloadSenders := make([]MachineNotifier, 0) 58 | 59 | // Remote API 60 | if config.ApiURL != "" { 61 | apiSender, err := NewAPISender(httpClient, config, logger) 62 | if err != nil { 63 | logger.Error("error creating new remote API sender: ", err) 64 | } else { 65 | payloadSenders = append(payloadSenders, apiSender) 66 | } 67 | } 68 | 69 | // Webhooks 70 | if config.WebhookURL != "" { 71 | webhookSender, err := NewWebhookSender(httpClient, config, logger) 72 | if err != nil { 73 | logger.Error("error creating new webhook sender: ", err) 74 | } else { 75 | payloadSenders = append(payloadSenders, webhookSender) 76 | } 77 | } 78 | 79 | // Pushgateway 80 | if config.PushgatewayURL != "" { 81 | pgwSender, err := NewPushgatewaySender(httpClient, config, logger) 82 | if err != nil { 83 | logger.Error("error creating new Pushgateway sender:", err) 84 | } else { 85 | payloadSenders = append(payloadSenders, pgwSender) 86 | } 87 | } 88 | 89 | // Discord 90 | if config.DiscordChannelID != "" && config.DiscordBotToken != "" { 91 | discordSender, err := NewDiscordSender(httpClient, config, logger) 92 | if err != nil { 93 | logger.Error("error creating new Discord sender:", err) 94 | } else { 95 | notificationSenders = append(notificationSenders, discordSender) 96 | } 97 | } 98 | 99 | return ¬ifier{ 100 | verbose: config.Verbose, 101 | chatNotifiers: notificationSenders, 102 | machineNotifiers: payloadSenders, 103 | logger: logger, 104 | } 105 | } 106 | 107 | func (n *notifier) SendChatNotifications(m string, failedCount int) error { 108 | var errs []error 109 | 110 | if len(n.chatNotifiers) == 0 { 111 | n.logger.Debug("no chat notification receivers configured, no notifications will be sent") 112 | 113 | return nil 114 | } 115 | 116 | for _, sender := range n.chatNotifiers { 117 | if err := sender.send(m, failedCount); err != nil { 118 | n.logger.Errorf("failed to send notification using %T: %v", sender, err) 119 | errs = append(errs, err) 120 | } 121 | } 122 | 123 | if len(errs) > 0 { 124 | return errors.Join(errs...) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (n *notifier) SendMachineNotifications(m *Results, failedCount int) error { 131 | var errs []error 132 | 133 | if len(n.machineNotifiers) == 0 { 134 | n.logger.Debug("no machine interface payload receivers configured, no notifications will be sent") 135 | 136 | return nil 137 | } 138 | 139 | for _, sender := range n.machineNotifiers { 140 | if err := sender.send(m, failedCount); err != nil { 141 | n.logger.Errorf("failed to send notification using %T: %v", sender, err) 142 | errs = append(errs, err) 143 | } 144 | } 145 | 146 | if len(errs) > 0 { 147 | return errors.Join(errs...) 148 | } 149 | 150 | return nil 151 | } 152 | -------------------------------------------------------------------------------- /pkg/alert/discord_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | ) 9 | 10 | func TestNewDiscordSender(t *testing.T) { 11 | mockHTTPClient := &SuccessStatusHTTPClient{} 12 | mockLogger := &MockLogger{} 13 | 14 | tests := []struct { 15 | name string 16 | botToken string 17 | channelID string 18 | notifySuccess bool 19 | expectErr bool 20 | expectedSender *discordSender 21 | }{ 22 | { 23 | name: "successful sender creation", 24 | botToken: "test", 25 | channelID: "-123", 26 | notifySuccess: false, 27 | expectErr: false, 28 | expectedSender: &discordSender{ 29 | botToken: "test", 30 | channelID: "-123", 31 | httpClient: mockHTTPClient, 32 | logger: mockLogger, 33 | notifySuccess: false, 34 | url: "https://discord.com/api/v10/channels/-123/messages", 35 | }, 36 | }, 37 | { 38 | name: "invalid channel ID returns error", 39 | botToken: "test", 40 | channelID: "1%ZZ", 41 | notifySuccess: false, 42 | expectErr: true, 43 | expectedSender: nil, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | cfg := &config.Config{ 50 | DiscordBotToken: tt.botToken, 51 | DiscordChannelID: tt.channelID, 52 | TextNotifySuccess: tt.notifySuccess, 53 | } 54 | 55 | actual, err := NewDiscordSender(mockHTTPClient, cfg, mockLogger) 56 | 57 | if tt.expectErr { 58 | if err == nil { 59 | t.Fatalf("expected error, got nil") 60 | } 61 | return 62 | } 63 | 64 | if err != nil { 65 | t.Fatalf("unexpected error: %v", err) 66 | } 67 | 68 | if !reflect.DeepEqual(tt.expectedSender, actual) { 69 | t.Errorf("expected %+v, got %+v", tt.expectedSender, actual) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestSend_Discord(t *testing.T) { 76 | newConfig := func(botToken, channelID string, notifySuccess bool) *config.Config { 77 | return &config.Config{ 78 | DiscordBotToken: botToken, 79 | DiscordChannelID: channelID, 80 | TextNotifySuccess: notifySuccess, 81 | } 82 | } 83 | 84 | tests := []struct { 85 | name string 86 | client HTTPClient 87 | rawMessage string 88 | failedCount int 89 | notifySuccess bool 90 | wantErr bool 91 | }{ 92 | { 93 | name: "success with failed checks", 94 | client: &SuccessStatusHTTPClient{}, 95 | rawMessage: "Test message", 96 | failedCount: 1, 97 | notifySuccess: false, 98 | wantErr: false, 99 | }, 100 | { 101 | name: "success with failed checks", 102 | client: &SuccessStatusHTTPClient{}, 103 | rawMessage: "Test message", 104 | failedCount: 1, 105 | notifySuccess: true, 106 | wantErr: false, 107 | }, 108 | { 109 | name: "success with failed checks", 110 | client: &SuccessStatusHTTPClient{}, 111 | rawMessage: "Test message", 112 | failedCount: 0, 113 | notifySuccess: false, 114 | wantErr: false, 115 | }, 116 | { 117 | name: "success with no failed checks and notify success enabled", 118 | client: &SuccessStatusHTTPClient{}, 119 | rawMessage: "Test message", 120 | failedCount: 0, 121 | notifySuccess: true, 122 | wantErr: false, 123 | }, 124 | { 125 | name: "Network Error When Sending Discord Message", 126 | client: &FailureHTTPClient{}, 127 | rawMessage: "Test message", 128 | failedCount: 1, 129 | notifySuccess: false, 130 | wantErr: true, 131 | }, 132 | { 133 | name: "Network Error When Sending Discord Message", 134 | client: &FailureHTTPClient{}, 135 | rawMessage: "Test message", 136 | failedCount: 1, 137 | notifySuccess: false, 138 | wantErr: true, 139 | }, 140 | { 141 | name: "Unexpected Response Code From Discord", 142 | client: &ErrorStatusHTTPClient{}, 143 | rawMessage: "Test message", 144 | failedCount: 1, 145 | notifySuccess: false, 146 | wantErr: true, 147 | }, 148 | { 149 | name: "Error Reading Response Body From Discord", 150 | client: &InvalidResponseBodyHTTPClient{}, 151 | rawMessage: "Test message", 152 | failedCount: 1, 153 | notifySuccess: false, 154 | wantErr: true, 155 | }, 156 | } 157 | 158 | for _, tt := range tests { 159 | t.Run(tt.name, func(t *testing.T) { 160 | sender, _ := NewDiscordSender(tt.client, newConfig("test", "-123", tt.notifySuccess), &MockLogger{}) 161 | err := sender.send(tt.rawMessage, tt.failedCount) 162 | if (err != nil) != tt.wantErr { 163 | t.Errorf("send() error = %v, wantErr %v", err, tt.wantErr) 164 | } 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /pkg/alert/helpers_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | const internalServerErrorResponse = "internal server error" 12 | 13 | // SuccessStatusHTTPClient is a mock HTTP client implementation which returns HTTP Success (200) status responses. 14 | type SuccessStatusHTTPClient struct{} 15 | 16 | func (c *SuccessStatusHTTPClient) Do(req *http.Request) (*http.Response, error) { 17 | return &http.Response{ 18 | StatusCode: 200, 19 | Body: io.NopCloser(strings.NewReader("mocked Do response")), 20 | }, nil 21 | } 22 | 23 | func (c *SuccessStatusHTTPClient) Get(url string) (*http.Response, error) { 24 | return &http.Response{ 25 | StatusCode: 200, 26 | Body: io.NopCloser(strings.NewReader("mocked Get response")), 27 | }, nil 28 | } 29 | 30 | func (c *SuccessStatusHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, error) { 31 | return &http.Response{ 32 | StatusCode: 200, 33 | Body: io.NopCloser(strings.NewReader("mocked Post response")), 34 | }, nil 35 | } 36 | 37 | // ErrorStatusHTTPClient is a mock HTTP client implementation which returns HTTP Internal Server Error (500) status responses. 38 | type ErrorStatusHTTPClient struct{} 39 | 40 | func (e *ErrorStatusHTTPClient) Do(req *http.Request) (*http.Response, error) { 41 | return &http.Response{ 42 | StatusCode: 500, 43 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 44 | Request: &http.Request{ 45 | URL: &url.URL{ 46 | Host: "vxn.dev", 47 | Path: "/", 48 | }, 49 | }, 50 | }, nil 51 | } 52 | 53 | func (e *ErrorStatusHTTPClient) Get(url string) (*http.Response, error) { 54 | return &http.Response{ 55 | StatusCode: 500, 56 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 57 | }, nil 58 | } 59 | 60 | func (e *ErrorStatusHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 61 | return &http.Response{ 62 | StatusCode: 500, 63 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 64 | }, nil 65 | } 66 | 67 | // FailureHTTPClient is a mock HTTP client implementation which simulates a failure to process the given request, returning nil as the response and an error. 68 | type FailureHTTPClient struct{} 69 | 70 | func (f *FailureHTTPClient) Do(req *http.Request) (*http.Response, error) { 71 | return nil, fmt.Errorf("mocked Do error") 72 | } 73 | 74 | func (f *FailureHTTPClient) Get(url string) (*http.Response, error) { 75 | return nil, fmt.Errorf("mocked Get error") 76 | } 77 | 78 | func (f *FailureHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 79 | return nil, fmt.Errorf("mocked Post error") 80 | } 81 | 82 | // InvalidBodyReadCloser implements the [io.ReadCloser] interface and simulates an error when calling Read(). 83 | type InvalidBodyReadCloser struct{} 84 | 85 | func (i *InvalidBodyReadCloser) Read(p []byte) (n int, err error) { 86 | return 0, fmt.Errorf("invalid body") 87 | } 88 | 89 | func (i *InvalidBodyReadCloser) Close() error { 90 | return nil 91 | } 92 | 93 | // InvalidResponseBodyHTTPClient is a mock HTTP client implementation which simulates an invalid response body to trigger an error when trying to read it. 94 | type InvalidResponseBodyHTTPClient struct{} 95 | 96 | func (i *InvalidResponseBodyHTTPClient) Do(req *http.Request) (*http.Response, error) { 97 | return &http.Response{ 98 | StatusCode: 500, 99 | Body: &InvalidBodyReadCloser{}, 100 | }, nil 101 | } 102 | 103 | func (i *InvalidResponseBodyHTTPClient) Get(url string) (*http.Response, error) { 104 | return &http.Response{ 105 | StatusCode: 500, 106 | Body: &InvalidBodyReadCloser{}, 107 | }, nil 108 | } 109 | 110 | func (i *InvalidResponseBodyHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 111 | return &http.Response{ 112 | StatusCode: 500, 113 | Body: &InvalidBodyReadCloser{}, 114 | }, nil 115 | } 116 | 117 | // MockLogger is a mock implementation of the Logger interface with empty method implementations. 118 | type MockLogger struct{} 119 | 120 | func (l *MockLogger) Trace(v ...any) {} 121 | func (l *MockLogger) Tracef(format string, v ...any) {} 122 | func (l *MockLogger) Debug(v ...any) {} 123 | func (l *MockLogger) Debugf(format string, v ...any) {} 124 | func (l *MockLogger) Info(v ...any) {} 125 | func (l *MockLogger) Infof(format string, v ...any) {} 126 | func (l *MockLogger) Warn(v ...any) {} 127 | func (l *MockLogger) Warnf(format string, v ...any) {} 128 | func (l *MockLogger) Error(v ...any) {} 129 | func (l *MockLogger) Errorf(format string, v ...any) {} 130 | func (l *MockLogger) Panic(v ...any) {} 131 | func (l *MockLogger) Panicf(format string, v ...any) {} 132 | -------------------------------------------------------------------------------- /pkg/socket/fetch.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "go.vxn.dev/dish/pkg/config" 13 | "go.vxn.dev/dish/pkg/logger" 14 | ) 15 | 16 | // fetchHandler provides methods to fetch sockets either from a file or from a remote API source. 17 | type fetchHandler struct { 18 | logger logger.Logger 19 | } 20 | 21 | // NewFetchHandler creates a new instance of fetchHandler. 22 | func NewFetchHandler(l logger.Logger) *fetchHandler { 23 | return &fetchHandler{ 24 | logger: l, 25 | } 26 | } 27 | 28 | // fetchSocketsFromFile opens a file and returns [io.ReadCloser] for reading from the stream. 29 | func (f *fetchHandler) fetchSocketsFromFile(config *config.Config) (io.ReadCloser, error) { 30 | file, err := os.Open(config.Source) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | f.logger.Debugf("fetching sockets from file (%s)", config.Source) 36 | 37 | return file, nil 38 | } 39 | 40 | // copyBody copies the provided response body to the provided buffer. The body is closed. 41 | func (f *fetchHandler) copyBody(body io.ReadCloser, buf *bytes.Buffer) (err error) { 42 | defer func() { 43 | if cerr := body.Close(); cerr != nil { 44 | cerr = fmt.Errorf("close error: %w", cerr) 45 | err = errors.Join(cerr, err) 46 | } 47 | }() 48 | 49 | _, err = buf.ReadFrom(body) 50 | return err 51 | } 52 | 53 | // fetchSocketsFromRemote loads the sockets to be monitored from a remote RESTful API endpoint. It returns the response body implementing [io.ReadCloser] for reading from and closing the stream. 54 | // 55 | // It uses a local cache if enabled and falls back to the network if the cache is not present or expired. If the network request fails and expired cache is available, it will be used. 56 | // 57 | // The url parameter must be a complete URL to a remote http/s server, including: 58 | // - Scheme (http:// or https://) 59 | // - Host (domain or IP) 60 | // - Optional port 61 | // - Optional path 62 | // - Optional query parameters 63 | // 64 | // Example url: http://api.example.com:5569/stream?query=variable 65 | func (f *fetchHandler) fetchSocketsFromRemote(config *config.Config) (io.ReadCloser, error) { 66 | cacheFilePath := hashUrlToFilePath(config.Source, config.ApiCacheDirectory) 67 | 68 | // If we do not want to cache sockets to the file, fetch from network 69 | if !config.ApiCacheSockets { 70 | return f.loadFreshSockets(config) 71 | } 72 | 73 | // If cache is enabled, try to load sockets from it first 74 | cachedReader, cacheTime, err := loadCachedSockets(cacheFilePath, config.ApiCacheTTLMinutes) 75 | // If cache is expired or fails to load, attempt to fetch fresh sockets 76 | if err != nil { 77 | f.logger.Warnf("cache unavailable for URL: %s (reason: %v); attempting network fetch", config.Source, err) 78 | 79 | // Fetch fresh sockets from network 80 | respBody, fetchErr := f.loadFreshSockets(config) 81 | if fetchErr != nil { 82 | // If the fetch fails and expired cache is not available, return the fetch error 83 | if err != ErrExpiredCache { 84 | return nil, fetchErr 85 | } 86 | // If the fetch fails and expired cache is available, return the expired cache and log a warning 87 | f.logger.Errorf("fetching socket list from remote API at %s failed: %v.", config.Source, fetchErr) 88 | f.logger.Warnf("using expired cache from %s", cacheTime.Format(time.RFC3339)) 89 | 90 | return cachedReader, nil 91 | } else { 92 | f.logger.Infof("socket list fetched from %s", config.Source) 93 | } 94 | 95 | var buf bytes.Buffer 96 | err = f.copyBody(respBody, &buf) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to copy response body: %w", err) 99 | } 100 | 101 | if err := saveSocketsToCache(cacheFilePath, config.ApiCacheDirectory, buf.Bytes()); err != nil { 102 | f.logger.Warnf("failed to save fetched sockets to cache: %v", err) 103 | } 104 | 105 | return io.NopCloser(bytes.NewReader(buf.Bytes())), nil 106 | } 107 | 108 | // Cache is valid (not expired, no error from file read) 109 | f.logger.Info("socket list fetched from cache") 110 | return cachedReader, err 111 | } 112 | 113 | // loadFreshSockets fetches fresh sockets from the remote source. 114 | func (f *fetchHandler) loadFreshSockets(config *config.Config) (io.ReadCloser, error) { 115 | req, err := http.NewRequest(http.MethodGet, config.Source, nil) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to create HTTP request: %w", err) 118 | } 119 | 120 | client := &http.Client{} 121 | req.Header.Set("Content-Type", "application/json") 122 | 123 | if config.ApiHeaderName != "" && config.ApiHeaderValue != "" { 124 | req.Header.Set(config.ApiHeaderName, config.ApiHeaderValue) 125 | } 126 | 127 | resp, err := client.Do(req) 128 | if err != nil { 129 | return nil, fmt.Errorf("network request failed: %w", err) 130 | } 131 | 132 | if resp.StatusCode != http.StatusOK { 133 | return nil, fmt.Errorf("failed to fetch sockets from remote source --- got %d (%s)", resp.StatusCode, resp.Status) 134 | } 135 | 136 | return resp.Body, nil 137 | } 138 | -------------------------------------------------------------------------------- /pkg/alert/webhook_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | ) 9 | 10 | func TestNewWebhookSender(t *testing.T) { 11 | mockHTTPClient := &SuccessStatusHTTPClient{} 12 | mockLogger := &MockLogger{} 13 | 14 | url := "https://abc123.xyz.com" 15 | notifySuccess := false 16 | verbose := false 17 | 18 | expected := &webhookSender{ 19 | httpClient: mockHTTPClient, 20 | url: url, 21 | notifySuccess: notifySuccess, 22 | verbose: verbose, 23 | logger: mockLogger, 24 | } 25 | 26 | cfg := &config.Config{ 27 | WebhookURL: url, 28 | Verbose: verbose, 29 | MachineNotifySuccess: notifySuccess, 30 | } 31 | actual, _ := NewWebhookSender(mockHTTPClient, cfg, mockLogger) 32 | 33 | if !reflect.DeepEqual(expected, actual) { 34 | t.Fatalf("expected %v, got %v", expected, actual) 35 | } 36 | } 37 | 38 | func TestSend_Webhook(t *testing.T) { 39 | url := "https://abc123.xyz.com" 40 | 41 | successResults := Results{ 42 | Map: map[string]bool{ 43 | "test": true, 44 | }, 45 | } 46 | failedResults := Results{ 47 | Map: map[string]bool{ 48 | "test": false, 49 | }, 50 | } 51 | mixedResults := Results{ 52 | Map: map[string]bool{ 53 | "test1": true, 54 | "test2": false, 55 | }, 56 | } 57 | 58 | newConfig := func(url string, notifySuccess, verbose bool) *config.Config { 59 | return &config.Config{ 60 | WebhookURL: url, 61 | Verbose: verbose, 62 | MachineNotifySuccess: notifySuccess, 63 | } 64 | } 65 | 66 | tests := []struct { 67 | name string 68 | client HTTPClient 69 | results Results 70 | failedCount int 71 | notifySuccess bool 72 | verbose bool 73 | wantErr bool 74 | }{ 75 | { 76 | name: "Failed Sockets", 77 | client: &SuccessStatusHTTPClient{}, 78 | results: failedResults, 79 | failedCount: 1, 80 | notifySuccess: false, 81 | verbose: false, 82 | wantErr: false, 83 | }, 84 | { 85 | name: "Failed Sockets - Verbose", 86 | client: &SuccessStatusHTTPClient{}, 87 | results: failedResults, 88 | failedCount: 1, 89 | notifySuccess: false, 90 | verbose: true, 91 | wantErr: false, 92 | }, 93 | { 94 | name: "No Failed Sockets With notifySuccess", 95 | client: &SuccessStatusHTTPClient{}, 96 | results: successResults, 97 | failedCount: 0, 98 | notifySuccess: true, 99 | verbose: false, 100 | wantErr: false, 101 | }, 102 | { 103 | name: "No Failed Sockets Without notifySuccess", 104 | client: &SuccessStatusHTTPClient{}, 105 | results: successResults, 106 | failedCount: 0, 107 | notifySuccess: false, 108 | verbose: false, 109 | wantErr: false, 110 | }, 111 | { 112 | name: "No Failed Sockets Without notifySuccess - Verbose", 113 | client: &SuccessStatusHTTPClient{}, 114 | results: successResults, 115 | failedCount: 0, 116 | notifySuccess: false, 117 | verbose: true, 118 | wantErr: false, 119 | }, 120 | { 121 | name: "Mixed Results With notifySuccess", 122 | client: &SuccessStatusHTTPClient{}, 123 | results: mixedResults, 124 | failedCount: 1, 125 | notifySuccess: true, 126 | verbose: false, 127 | wantErr: false, 128 | }, 129 | { 130 | name: "Mixed Results Without notifySuccess", 131 | client: &SuccessStatusHTTPClient{}, 132 | results: mixedResults, 133 | failedCount: 1, 134 | notifySuccess: false, 135 | verbose: false, 136 | wantErr: false, 137 | }, 138 | { 139 | name: "Network Error When Pushing to Webhook", 140 | client: &FailureHTTPClient{}, 141 | results: failedResults, 142 | failedCount: 1, 143 | notifySuccess: false, 144 | verbose: false, 145 | wantErr: true, 146 | }, 147 | { 148 | name: "Unexpected Response Code From Webhook", 149 | client: &ErrorStatusHTTPClient{}, 150 | results: failedResults, 151 | failedCount: 1, 152 | notifySuccess: false, 153 | verbose: false, 154 | wantErr: true, 155 | }, 156 | { 157 | name: "Error Reading Response Body From Webhook", 158 | client: &InvalidResponseBodyHTTPClient{}, 159 | results: failedResults, 160 | failedCount: 1, 161 | notifySuccess: false, 162 | verbose: true, 163 | wantErr: true, 164 | }, 165 | } 166 | 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | cfg := newConfig(url, tt.verbose, tt.notifySuccess) 170 | sender, err := NewWebhookSender(tt.client, cfg, &MockLogger{}) 171 | if err != nil { 172 | t.Fatalf("failed to create Webhook sender instance: %v", err) 173 | } 174 | 175 | err = sender.send(&tt.results, tt.failedCount) 176 | if tt.wantErr != (err != nil) { 177 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 178 | } 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /pkg/netrunner/runner.go: -------------------------------------------------------------------------------- 1 | // Package netrunner provides functionality for checking the availability of sockets and/or endpoints. 2 | // It provides tcpRunner, httpRunner and icmpRunner structs implementing the NetRunner interface, which can be used to 3 | // run checks on the provided targets. 4 | package netrunner 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "regexp" 12 | "slices" 13 | "strconv" 14 | "sync" 15 | "time" 16 | 17 | "go.vxn.dev/dish/pkg/config" 18 | "go.vxn.dev/dish/pkg/logger" 19 | "go.vxn.dev/dish/pkg/socket" 20 | ) 21 | 22 | const agentVersion = "1.12" 23 | 24 | // RunSocketTest is intended to be invoked in a separate goroutine. 25 | // It runs a test for the given socket and sends the result through the given channel. 26 | // If the test fails to start, the error is logged to STDOUT and no result is 27 | // sent. On return, Done() is called on the WaitGroup and the channel is closed. 28 | func RunSocketTest(sock socket.Socket, out chan<- socket.Result, wg *sync.WaitGroup, cfg *config.Config, logger logger.Logger) { 29 | defer wg.Done() 30 | defer close(out) 31 | 32 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second) 33 | defer cancel() 34 | 35 | runner, err := NewNetRunner(sock, logger) 36 | if err != nil { 37 | logger.Errorf("failed to test socket: %v", err.Error()) 38 | return 39 | } 40 | 41 | out <- runner.RunTest(ctx, sock) 42 | } 43 | 44 | // NetRunner is used to run tests for a socket. 45 | type NetRunner interface { 46 | RunTest(ctx context.Context, sock socket.Socket) socket.Result 47 | } 48 | 49 | // NewNetRunner determines the protocol used for the socket test and creates a 50 | // new NetRunner for it. 51 | // 52 | // Rules for the test method determination (first matching rule applies): 53 | // - If socket.Host starts with 'http://' or 'https://', a HTTP runner is returned. 54 | // - If socket.Port is between 1 and 65535, a TCP runner is returned. 55 | // - If socket.Host is not empty, an ICMP runner is returned. 56 | // - If none of the above conditions are met, a non-nil error is returned. 57 | func NewNetRunner(sock socket.Socket, logger logger.Logger) (NetRunner, error) { 58 | exp, err := regexp.Compile("^(http|https)://") 59 | if err != nil { 60 | return nil, fmt.Errorf("regex compilation failed: %w", err) 61 | } 62 | 63 | if exp.MatchString(sock.Host) { 64 | return &httpRunner{client: &http.Client{}, logger: logger}, nil 65 | } 66 | 67 | if sock.Port >= 1 && sock.Port <= 65535 { 68 | return &tcpRunner{logger: logger}, nil 69 | } 70 | 71 | if sock.Host != "" { 72 | return &icmpRunner{logger: logger}, nil 73 | } 74 | 75 | return nil, fmt.Errorf("no protocol could be determined from the socket %s", sock.ID) 76 | } 77 | 78 | type tcpRunner struct { 79 | logger logger.Logger 80 | } 81 | 82 | // RunTest is used to test TCP sockets. It opens a TCP connection with the given socket. 83 | // The test passes if the connection is successfully opened with no errors. 84 | func (runner *tcpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 85 | endpoint := net.JoinHostPort(sock.Host, strconv.Itoa(sock.Port)) 86 | 87 | runner.logger.Debug("TCP runner: connect: " + endpoint) 88 | 89 | d := net.Dialer{} 90 | 91 | conn, err := d.DialContext(ctx, "tcp", endpoint) 92 | if err != nil { 93 | return socket.Result{Socket: sock, Error: err, Passed: false} 94 | } 95 | 96 | defer func() { 97 | if err := conn.Close(); err != nil { 98 | runner.logger.Errorf( 99 | "failed to close TCP connection to %s: %v", 100 | endpoint, err, 101 | ) 102 | } 103 | }() 104 | 105 | return socket.Result{Socket: sock, Passed: true} 106 | } 107 | 108 | type httpRunner struct { 109 | client *http.Client 110 | logger logger.Logger 111 | } 112 | 113 | // RunTest is used to test HTTP/S endpoints exclusively. It executes a HTTP GET 114 | // request to the given socket. The test passes if the request did not end with 115 | // an error and the response status matches the expected HTTP codes. 116 | func (runner *httpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 117 | url := sock.Host + ":" + strconv.Itoa(sock.Port) + sock.PathHTTP 118 | 119 | runner.logger.Debug("HTTP runner: connect:", url) 120 | 121 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 122 | if err != nil { 123 | return socket.Result{Socket: sock, Passed: false, Error: err} 124 | } 125 | req.Header.Set("User-Agent", fmt.Sprintf("dish/%s", agentVersion)) 126 | 127 | resp, err := runner.client.Do(req) 128 | if err != nil { 129 | return socket.Result{Socket: sock, Passed: false, Error: err} 130 | } 131 | 132 | defer func() { 133 | if cerr := resp.Body.Close(); cerr != nil { 134 | runner.logger.Errorf("failed to close body for %v", cerr) 135 | } 136 | }() 137 | 138 | if !slices.Contains(sock.ExpectedHTTPCodes, resp.StatusCode) { 139 | err = fmt.Errorf("expected codes: %v, got %d", sock.ExpectedHTTPCodes, resp.StatusCode) 140 | } 141 | 142 | return socket.Result{ 143 | Socket: sock, 144 | Passed: slices.Contains(sock.ExpectedHTTPCodes, resp.StatusCode), 145 | ResponseCode: resp.StatusCode, 146 | Error: err, 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # dish / Makefile 3 | # 4 | 5 | # 6 | # VARS 7 | # 8 | 9 | include .env.example 10 | -include .env 11 | 12 | PROJECT_NAME?=${APP_NAME} 13 | 14 | # release binaries build vars 15 | MAIN_PATH?=./cmd/dish/ 16 | LATEST_TAG?=$(shell git describe --tags --abbrev=0 | sed 's/^v//') 17 | 18 | 19 | DOCKER_DEV_IMAGE?=${PROJECT_NAME}-image 20 | DOCKER_DEV_CONTAINER?=${PROJECT_NAME}-run 21 | DOCKER_TEST_CONTAINER?=${PROJECT_NAME}-test 22 | 23 | COMPOSE_FILE=deployments/docker-compose.yml 24 | COMPOSE_FILE_TEST=./docker-compose.test.yml 25 | 26 | # define standard colors 27 | # https://gist.github.com/rsperl/d2dfe88a520968fbc1f49db0a29345b9 28 | ifneq (,$(findstring xterm,${TERM})) 29 | BLACK := $(shell tput -Txterm setaf 0) 30 | RED := $(shell tput -Txterm setaf 1) 31 | GREEN := $(shell tput -Txterm setaf 2) 32 | YELLOW := $(shell tput -Txterm setaf 3) 33 | LIGHTPURPLE := $(shell tput -Txterm setaf 4) 34 | PURPLE := $(shell tput -Txterm setaf 5) 35 | BLUE := $(shell tput -Txterm setaf 6) 36 | WHITE := $(shell tput -Txterm setaf 7) 37 | RESET := $(shell tput -Txterm sgr0) 38 | else 39 | BLACK := "" 40 | RED := "" 41 | GREEN := "" 42 | YELLOW := "" 43 | LIGHTPURPLE := "" 44 | PURPLE := "" 45 | BLUE := "" 46 | WHITE := "" 47 | RESET := "" 48 | endif 49 | 50 | export 51 | 52 | # 53 | # FUNCTIONS 54 | # 55 | 56 | define print_info 57 | @echo -e "\n>>> ${YELLOW}${1}${RESET}\n" 58 | endef 59 | 60 | define update_semver 61 | $(call print_info, Incrementing semver to ${1}...) 62 | @[ -f ".env" ] || cp .env.example .env 63 | @sed -i 's|APP_VERSION=.*|APP_VERSION=${1}|' .env 64 | @sed -i 's|APP_VERSION=.*|APP_VERSION=${1}|' .env.example 65 | endef 66 | 67 | # 68 | # TARGETS 69 | # 70 | 71 | all: info 72 | 73 | .PHONY: build local_build logs major minor patch push run stop test version lint lint-fix format 74 | info: 75 | @echo -e "\n${GREEN} ${PROJECT_NAME} / Makefile ${RESET}\n" 76 | 77 | @echo -e "${YELLOW} make test --- run unit tests (go test) ${RESET}" 78 | @echo -e "${YELLOW} make build --- build project (docker image) ${RESET}" 79 | @echo -e "${YELLOW} make run --- run project ${RESET}" 80 | @echo -e "${YELLOW} make logs --- fetch container's logs ${RESET}" 81 | @echo -e "${YELLOW} make stop --- stop and purge project (only docker containers!) ${RESET}\n" 82 | 83 | build: 84 | @echo -e "\n${YELLOW} Building project (docker-compose build)... ${RESET}\n" 85 | @docker compose -f ${COMPOSE_FILE} build 86 | 87 | local_build: 88 | @echo -e "\n${YELLOW} [local] Building project... ${RESET}\n" 89 | @go mod tidy 90 | @go build -tags dev -o bin/ ${MAIN_PATH} 91 | 92 | run: build 93 | @echo -e "\n${YELLOW} Starting project (docker-compose up)... ${RESET}\n" 94 | @docker compose -f ${COMPOSE_FILE} up --force-recreate 95 | 96 | logs: 97 | @echo -e "\n${YELLOW} Fetching container's logs (CTRL-C to exit)... ${RESET}\n" 98 | @docker logs ${DOCKER_DEV_CONTAINER} -f 99 | 100 | stop: 101 | @echo -e "\n${YELLOW} Stopping and purging project (docker-compose down)... ${RESET}\n" 102 | @docker compose -f ${COMPOSE_FILE} down 103 | 104 | test: 105 | @go test -v -coverprofile cover.out ./... 106 | @go tool cover -html cover.out -o cover.html 107 | @open cover.html 108 | 109 | docker-test: 110 | @echo -e "\n${YELLOW} Running tests... ${RESET}\n" 111 | @docker compose -f ${COMPOSE_FILE_TEST} build --no-cache 112 | @docker compose -f ${COMPOSE_FILE_TEST} up --force-recreate --exit-code-from dish 113 | 114 | push: 115 | @git tag -fa 'v${APP_VERSION}' -m 'v${APP_VERSION}' 116 | @git push --follow-tags --set-upstream origin master 117 | 118 | format: 119 | @echo -e "\nFormating code with golangci-lint..." 120 | @golangci-lint fmt \ 121 | --config .golangci.yaml \ 122 | ./... 123 | 124 | lint: 125 | @echo -e "\nRunning golangci-lint..." 126 | @golangci-lint run \ 127 | --config .golangci.yaml \ 128 | --timeout 2m \ 129 | ./... 130 | 131 | lint-fix: 132 | @echo -e "\nAuto-fixing with golangci-lint..." 133 | @golangci-lint run --fix \ 134 | --config .golangci.yaml \ 135 | --timeout 2m \ 136 | ./... 137 | 138 | MAJOR := $(shell echo ${APP_VERSION} | cut -d. -f1) 139 | MINOR := $(shell echo ${APP_VERSION} | cut -d. -f2) 140 | PATCH := $(shell echo ${APP_VERSION} | cut -d. -f3) 141 | 142 | major: 143 | $(eval APP_VERSION := $(shell echo $$(( ${MAJOR} + 1 )).0.0)) 144 | $(call update_semver,${APP_VERSION}) 145 | 146 | minor: 147 | $(eval APP_VERSION := $(shell echo ${MAJOR}.$$(( ${MINOR} + 1 )).0)) 148 | $(call update_semver,${APP_VERSION}) 149 | 150 | patch: 151 | $(eval APP_VERSION := $(shell echo ${MAJOR}.${MINOR}.$$(( ${PATCH} + 1 )))) 152 | $(call update_semver,${APP_VERSION}) 153 | 154 | version: 155 | $(call print_info, Current version: ${APP_VERSION}...) 156 | 157 | binaries: 158 | @GOARCH=arm64 GOOS=linux go build -o dish-${LATEST_TAG}.linux-arm64 ${MAIN_PATH} 159 | @gzip dish-${LATEST_TAG}.linux-arm64 160 | @GOARCH=amd64 GOOS=linux go build -o dish-${LATEST_TAG}.linux-x86_64 ${MAIN_PATH} 161 | @gzip dish-${LATEST_TAG}.linux-x86_64 162 | @GOARCH=amd64 GOOS=windows go build -o dish-${LATEST_TAG}.windows-x86_64.exe ${MAIN_PATH} 163 | @gzip dish-${LATEST_TAG}.windows-x86_64.exe 164 | @GOARCH=arm64 GOOS=darwin go build -o dish-${LATEST_TAG}.macos-arm64 ${MAIN_PATH} 165 | @gzip dish-${LATEST_TAG}.macos-arm64 166 | @GOARCH=amd64 GOOS=darwin go build -o dish-${LATEST_TAG}.macos-x86_64 ${MAIN_PATH} 167 | @gzip dish-${LATEST_TAG}.macos-x86_64 168 | 169 | ifeq (${SONAR_HOST_URL}${SONAR_TOKEN},) 170 | sonar_check: 171 | else 172 | sonar_check: 173 | $(call print_info, Starting the sonarqube code analysis...) 174 | @docker run --rm \ 175 | --dns ${DNS_NAMESERVER} \ 176 | -e SONAR_HOST_URL="${SONAR_HOST_URL}" \ 177 | -e SONAR_TOKEN="${SONAR_TOKEN}" \ 178 | -v ".:/usr/src" \ 179 | sonarsource/sonar-scanner-cli 180 | endif 181 | 182 | -------------------------------------------------------------------------------- /pkg/netrunner/runner_posix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package netrunner 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "encoding/binary" 9 | "errors" 10 | "fmt" 11 | "net" 12 | "runtime" 13 | "syscall" 14 | "time" 15 | 16 | "go.vxn.dev/dish/pkg/logger" 17 | "go.vxn.dev/dish/pkg/socket" 18 | ) 19 | 20 | type ICMPType int 21 | 22 | const ( 23 | echoReply ICMPType = 0 24 | echoRequest ICMPType = 8 25 | ) 26 | 27 | const ( 28 | ipStripHdr = 23 29 | testID = 0x1234 30 | testSeq = 0x0001 31 | ) 32 | 33 | type icmpRunner struct { 34 | logger logger.Logger 35 | } 36 | 37 | // RunTest is used to test ICMP sockets. It sends an ICMP Echo Request to the given socket using 38 | // non-privileged ICMP and verifies the reply. The test passes if the reply has the same payload 39 | // as the request. Returns an error if the socket host cannot be resolved to an IPv4 address. If 40 | // the host resolves to more than one address, only the first one is used. 41 | func (runner *icmpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 42 | runner.logger.Debugf("Resolving host '%s' to an IP address", sock.Host) 43 | 44 | addr, err := net.DefaultResolver.LookupIPAddr(ctx, sock.Host) 45 | if err != nil { 46 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to resolve socket host: %w", err)} 47 | } 48 | 49 | ip := addr[0].IP.To4() 50 | if ip == nil { 51 | return socket.Result{Socket: sock, Error: errors.New("not a valid IPv4 address")} 52 | } 53 | 54 | sockAddr := &syscall.SockaddrInet4{Addr: [4]byte(ip)} 55 | 56 | // When using ICMP over DGRAM, Linux Kernel automatically sets (overwrites) and 57 | // validates the id, seq and checksum of each incoming and outgoing ICMP message. 58 | // This is largely non-documented in the linux man pages. The closest I found is: 59 | // - (Linux news) lwn.net/Articles/420800/ 60 | // - (MacOS man) https://www.manpagez.com/man/4/icmp/ 61 | // - (Third-party article) https://inc0x0.com/icmp-ip-packets-ping-manually-create-and-send-icmp-ip-packets/ 62 | // "[...] most Linux systems use a unique identifier for every ping process, and sequence 63 | // number is an increasing number within that process. Windows uses a fixed identifier, which 64 | // varies between Windows versions, and a sequence number that is only reset at boot time." 65 | sysSocket, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_ICMP) 66 | if err != nil { 67 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to create a non-privileged icmp socket: %w", err)} 68 | } 69 | 70 | defer func() { 71 | if cerr := syscall.Close(sysSocket); cerr != nil { 72 | runner.logger.Errorf( 73 | "error closing ICMP socket (fd %d) for %s:%d: %v", 74 | sysSocket, sock.Host, sock.Port, cerr, 75 | ) 76 | } 77 | }() 78 | 79 | if runtime.GOOS == "darwin" { 80 | if err := syscall.SetsockoptInt(sysSocket, syscall.IPPROTO_IP, ipStripHdr, 1); err != nil { 81 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to set ip strip header: %w", err)} 82 | } 83 | } 84 | 85 | if d, ok := ctx.Deadline(); ok { 86 | // Set a socket receive timeout. 87 | t := syscall.NsecToTimeval(time.Until(d).Nanoseconds()) 88 | if err := syscall.SetsockoptTimeval(sysSocket, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &t); err != nil { 89 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to set a timeout on a non-privileged icmp socket: %w", err)} 90 | } 91 | } 92 | 93 | payload := []byte("ICMP echo") 94 | 95 | // ICMP Header size is 8 bytes. 96 | reqBuf := make([]byte, 8+len(payload)) 97 | 98 | // ICMP Header. 99 | // ID, Seq and Checksum are filled in automatically by the kernel on linux machines, not on darwin ipv4 100 | reqBuf[0] = byte(echoRequest) // Type: Echo 101 | copy(reqBuf[8:], payload) 102 | 103 | // Set the ID, Seq and Checksum for the darwin based machines 104 | if runtime.GOOS == "darwin" { 105 | binary.BigEndian.PutUint16(reqBuf[4:6], testID) 106 | binary.BigEndian.PutUint16(reqBuf[6:8], testSeq) 107 | csum := checksum(reqBuf) 108 | reqBuf[2] ^= byte(csum) 109 | reqBuf[3] ^= byte(csum >> 8) 110 | } 111 | 112 | runner.logger.Debug("ICMP runner: send to " + ip.String()) 113 | 114 | if err := syscall.Sendto(sysSocket, reqBuf, 0, sockAddr); err != nil { 115 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to send an echo request: %w", err)} 116 | } 117 | 118 | // Maximum Transmission Unit (MTU) equals 1500 bytes. 119 | // Recvfrom before writing to the buffer, checks its length (not capacity). 120 | // If the length of the buffer is too small to fit the data then it's silently truncated. 121 | replyBuf := make([]byte, 1500) 122 | 123 | runner.logger.Debug("ICMP runner: recv from " + ip.String()) 124 | 125 | n, _, err := syscall.Recvfrom(sysSocket, replyBuf, 0) 126 | if err != nil { 127 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to receive a reply from a socket: %w", err)} 128 | } 129 | 130 | if n < 8 { 131 | return socket.Result{Socket: sock, Error: fmt.Errorf("reply is too short: received %d bytes ", n)} 132 | } 133 | 134 | if replyBuf[0] != byte(echoReply) { 135 | return socket.Result{Socket: sock, Error: errors.New("received unexpected reply type")} 136 | } 137 | 138 | if !bytes.Equal(reqBuf[8:], replyBuf[8:n]) { 139 | return socket.Result{Socket: sock, Error: errors.New("failed to validate echo reply: payloads are not equal")} 140 | } 141 | 142 | return socket.Result{Socket: sock, Passed: true} 143 | } 144 | 145 | // checksum calculates the internet checksum for the given byte slice. 146 | // This function was taken from the x/net/icmp package, which is not available in the standard library. 147 | // https://godoc.org/golang.org/x/net/icmp 148 | func checksum(b []byte) uint16 { 149 | csumcv := len(b) - 1 // checksum coverage 150 | s := uint32(0) 151 | for i := 0; i < csumcv; i += 2 { 152 | s += uint32(b[i+1])<<8 | uint32(b[i]) 153 | } 154 | if csumcv&1 == 0 { 155 | s += uint32(b[csumcv]) 156 | } 157 | s = s>>16 + s&0xffff 158 | s = s + s>>16 159 | return ^uint16(s) 160 | } 161 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config provides access to configuration parameters 2 | // set via flags or args. 3 | package config 4 | 5 | import ( 6 | "errors" 7 | "flag" 8 | "fmt" 9 | ) 10 | 11 | // Config holds the configuration parameters. 12 | type Config struct { 13 | InstanceName string 14 | ApiHeaderName string 15 | ApiHeaderValue string 16 | ApiCacheSockets bool 17 | ApiCacheDirectory string 18 | ApiCacheTTLMinutes uint 19 | Source string 20 | Verbose bool 21 | PushgatewayURL string 22 | TelegramBotToken string 23 | TelegramChatID string 24 | TimeoutSeconds uint 25 | ApiURL string 26 | WebhookURL string 27 | TextNotifySuccess bool 28 | MachineNotifySuccess bool 29 | DiscordBotToken string 30 | DiscordChannelID string 31 | } 32 | 33 | const ( 34 | defaultInstanceName = "generic-dish" 35 | defaultApiHeaderName = "" 36 | defaultApiHeaderValue = "" 37 | defaultApiCacheSockets = false 38 | defaultApiCacheDir = ".cache" 39 | defaultApiCacheTTLMinutes = 10 40 | defaultVerbose = false 41 | defaultPushgatewayURL = "" 42 | defaultTelegramBotToken = "" 43 | defaultTelegramChatID = "" 44 | defaultTimeoutSeconds = 10 45 | defaultApiURL = "" 46 | defaultWebhookURL = "" 47 | defaultTextNotifySuccess = false 48 | defaultMachineNotifySuccess = false 49 | defaultDiscordBotToken = "" 50 | defaultDiscordChannelID = "" 51 | ) 52 | 53 | // ErrNoSourceProvided is returned when no source of sockets is specified. 54 | var ErrNoSourceProvided = errors.New("no source provided") 55 | 56 | // defineFlags defines flags on the provided FlagSet. The values of the flags are stored in the provided Config when parsed. 57 | func defineFlags(fs *flag.FlagSet, cfg *Config) { 58 | // System flags 59 | fs.StringVar(&cfg.InstanceName, "name", defaultInstanceName, "a string, dish instance name") 60 | fs.UintVar(&cfg.TimeoutSeconds, "timeout", defaultTimeoutSeconds, "an int, timeout in seconds for http and tcp calls") 61 | fs.BoolVar(&cfg.Verbose, "verbose", defaultVerbose, "a bool, console stdout logging toggle, output is colored unless disabled by NO_COLOR=true environment variable") 62 | 63 | // Integration channels flags 64 | // 65 | // General: 66 | fs.BoolVar(&cfg.TextNotifySuccess, "textNotifySuccess", defaultTextNotifySuccess, "a bool, specifies whether successful checks with no failures should be reported to text channels") 67 | fs.BoolVar(&cfg.MachineNotifySuccess, "machineNotifySuccess", defaultMachineNotifySuccess, "a bool, specifies whether successful checks with no failures should be reported to machine channels") 68 | 69 | // API socket source: 70 | fs.StringVar(&cfg.ApiHeaderName, "hname", defaultApiHeaderName, "a string, name of a custom additional header to be used when fetching and pushing results to the remote API (used mainly for auth purposes)") 71 | fs.StringVar(&cfg.ApiHeaderValue, "hvalue", defaultApiHeaderValue, "a string, value of the custom additional header to be used when fetching and pushing results to the remote API (used mainly for auth purposes)") 72 | fs.BoolVar(&cfg.ApiCacheSockets, "cache", defaultApiCacheSockets, "a bool, specifies whether to cache the socket list fetched from the remote API source") 73 | fs.StringVar(&cfg.ApiCacheDirectory, "cacheDir", defaultApiCacheDir, "a string, specifies the directory used to cache the socket list fetched from the remote API source") 74 | fs.UintVar(&cfg.ApiCacheTTLMinutes, "cacheTTL", defaultApiCacheTTLMinutes, "an int, time duration (in minutes) for which the cached list of sockets is valid") 75 | 76 | // Pushgateway: 77 | fs.StringVar(&cfg.PushgatewayURL, "target", defaultPushgatewayURL, "a string, result update path/URL to pushgateway, plaintext/byte output") 78 | 79 | // Telegram: 80 | fs.StringVar(&cfg.TelegramBotToken, "telegramBotToken", defaultTelegramBotToken, "a string, Telegram bot private token") 81 | fs.StringVar(&cfg.TelegramChatID, "telegramChatID", defaultTelegramChatID, "a string, Telegram chat/channel ID") 82 | 83 | // API for pushing results: 84 | fs.StringVar(&cfg.ApiURL, "updateURL", defaultApiURL, "a string, API endpoint URL for pushing results") 85 | 86 | // Webhooks: 87 | fs.StringVar(&cfg.WebhookURL, "webhookURL", defaultWebhookURL, "a string, URL of webhook endpoint") 88 | 89 | // Discord: 90 | fs.StringVar(&cfg.DiscordBotToken, "discordBotToken", defaultDiscordBotToken, "a string, Discord bot token") 91 | fs.StringVar(&cfg.DiscordChannelID, "discordChannelId", defaultDiscordChannelID, "a string, Discord channel ID") 92 | } 93 | 94 | // NewConfig returns a new instance of Config. 95 | // 96 | // If a flag is used for a supported config parameter, the config parameter's value is set according to the provided flag. Otherwise, a default value is used for the given parameter. 97 | func NewConfig(fs *flag.FlagSet, args []string) (*Config, error) { 98 | if fs == nil { 99 | // fs = flag.CommandLine 100 | return nil, fmt.Errorf("flagset argument cannot be nil") 101 | } 102 | 103 | cfg := &Config{ 104 | InstanceName: defaultInstanceName, 105 | ApiHeaderName: defaultApiHeaderName, 106 | ApiHeaderValue: defaultApiHeaderValue, 107 | ApiCacheSockets: defaultApiCacheSockets, 108 | ApiCacheDirectory: defaultApiCacheDir, 109 | ApiCacheTTLMinutes: defaultApiCacheTTLMinutes, 110 | Verbose: defaultVerbose, 111 | PushgatewayURL: defaultPushgatewayURL, 112 | TelegramBotToken: defaultTelegramBotToken, 113 | TelegramChatID: defaultTelegramChatID, 114 | TimeoutSeconds: defaultTimeoutSeconds, 115 | ApiURL: defaultApiURL, 116 | WebhookURL: defaultWebhookURL, 117 | DiscordBotToken: defaultDiscordBotToken, 118 | DiscordChannelID: defaultDiscordChannelID, 119 | } 120 | 121 | defineFlags(fs, cfg) 122 | 123 | if err := fs.Parse(args); err != nil { 124 | return nil, fmt.Errorf("error parsing flags: %w", err) 125 | } 126 | 127 | parsedArgs := fs.Args() 128 | 129 | // If no source is provided, return an error 130 | if len(parsedArgs) == 0 { 131 | return nil, ErrNoSourceProvided 132 | } 133 | // Otherwise, store the source in the config 134 | cfg.Source = parsedArgs[0] 135 | 136 | return cfg, nil 137 | } 138 | -------------------------------------------------------------------------------- /pkg/alert/api_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | ) 9 | 10 | func TestNewAPISender(t *testing.T) { 11 | mockHTTPClient := &SuccessStatusHTTPClient{} 12 | 13 | headerName := "X-Api-Key" 14 | headerValue := "abc123" 15 | notifySuccess := false 16 | verbose := false 17 | 18 | expected := &apiSender{ 19 | httpClient: mockHTTPClient, 20 | url: pushgatewayURL, 21 | headerName: headerName, 22 | headerValue: headerValue, 23 | notifySuccess: notifySuccess, 24 | verbose: verbose, 25 | logger: &MockLogger{}, 26 | } 27 | 28 | cfg := &config.Config{ 29 | ApiURL: pushgatewayURL, 30 | ApiHeaderName: headerName, 31 | ApiHeaderValue: headerValue, 32 | MachineNotifySuccess: notifySuccess, 33 | Verbose: verbose, 34 | } 35 | 36 | actual, _ := NewAPISender(mockHTTPClient, cfg, &MockLogger{}) 37 | 38 | if !reflect.DeepEqual(expected, actual) { 39 | t.Fatalf("expected %v, got %v", expected, actual) 40 | } 41 | } 42 | 43 | func TestSend_API(t *testing.T) { 44 | headerName := "X-Api-Key" 45 | headerValue := "abc123" 46 | 47 | successResults := Results{ 48 | Map: map[string]bool{ 49 | "test": true, 50 | }, 51 | } 52 | failedResults := Results{ 53 | Map: map[string]bool{ 54 | "test": false, 55 | }, 56 | } 57 | mixedResults := Results{ 58 | Map: map[string]bool{ 59 | "test1": true, 60 | "test2": false, 61 | }, 62 | } 63 | 64 | newConfig := func(headerName, headerValue string, notifySuccess, verbose bool) *config.Config { 65 | return &config.Config{ 66 | ApiURL: pushgatewayURL, 67 | MachineNotifySuccess: notifySuccess, 68 | Verbose: verbose, 69 | ApiHeaderName: headerName, 70 | ApiHeaderValue: headerValue, 71 | } 72 | } 73 | 74 | tests := []struct { 75 | name string 76 | client HTTPClient 77 | results Results 78 | failedCount int 79 | notifySuccess bool 80 | headerName string 81 | headerValue string 82 | verbose bool 83 | wantErr bool 84 | }{ 85 | { 86 | name: "Failed Sockets", 87 | client: &SuccessStatusHTTPClient{}, 88 | results: failedResults, 89 | failedCount: 1, 90 | notifySuccess: false, 91 | headerName: headerName, 92 | headerValue: headerValue, 93 | verbose: false, 94 | wantErr: false, 95 | }, 96 | { 97 | name: "Failed Sockets - Verbose", 98 | client: &SuccessStatusHTTPClient{}, 99 | results: failedResults, 100 | failedCount: 1, 101 | notifySuccess: false, 102 | headerName: headerName, 103 | headerValue: headerValue, 104 | verbose: true, 105 | wantErr: false, 106 | }, 107 | { 108 | name: "No Failed Sockets With notifySuccess", 109 | client: &SuccessStatusHTTPClient{}, 110 | results: successResults, 111 | failedCount: 0, 112 | notifySuccess: true, 113 | headerName: headerName, 114 | headerValue: headerValue, 115 | verbose: false, 116 | wantErr: false, 117 | }, 118 | { 119 | name: "No Failed Sockets Without notifySuccess", 120 | client: &SuccessStatusHTTPClient{}, 121 | results: successResults, 122 | failedCount: 0, 123 | notifySuccess: false, 124 | headerName: headerName, 125 | headerValue: headerValue, 126 | verbose: false, 127 | wantErr: false, 128 | }, 129 | { 130 | name: "No Failed Sockets Without notifySuccess - Verbose", 131 | client: &SuccessStatusHTTPClient{}, 132 | results: successResults, 133 | failedCount: 0, 134 | notifySuccess: false, 135 | headerName: headerName, 136 | headerValue: headerValue, 137 | verbose: true, 138 | wantErr: false, 139 | }, 140 | { 141 | name: "Mixed Results With notifySuccess", 142 | client: &SuccessStatusHTTPClient{}, 143 | results: mixedResults, 144 | failedCount: 1, 145 | notifySuccess: true, 146 | headerName: "", 147 | headerValue: "", 148 | verbose: false, 149 | wantErr: false, 150 | }, 151 | { 152 | name: "Mixed Results Without notifySuccess", 153 | client: &SuccessStatusHTTPClient{}, 154 | results: mixedResults, 155 | failedCount: 1, 156 | notifySuccess: false, 157 | headerName: "", 158 | headerValue: "", 159 | verbose: false, 160 | wantErr: false, 161 | }, 162 | { 163 | name: "No Custom Header", 164 | client: &SuccessStatusHTTPClient{}, 165 | results: failedResults, 166 | failedCount: 1, 167 | notifySuccess: false, 168 | headerName: "", 169 | headerValue: "", 170 | verbose: false, 171 | wantErr: false, 172 | }, 173 | { 174 | name: "Network Error When Pushing to Remote API", 175 | client: &FailureHTTPClient{}, 176 | results: failedResults, 177 | failedCount: 1, 178 | notifySuccess: false, 179 | headerName: headerName, 180 | headerValue: headerValue, 181 | verbose: false, 182 | wantErr: true, 183 | }, 184 | { 185 | name: "Unexpected Response Code From Remote API", 186 | client: &ErrorStatusHTTPClient{}, 187 | results: failedResults, 188 | failedCount: 1, 189 | notifySuccess: false, 190 | headerName: headerName, 191 | headerValue: headerValue, 192 | verbose: false, 193 | wantErr: true, 194 | }, 195 | { 196 | name: "Error Reading Response Body From Remote API", 197 | client: &InvalidResponseBodyHTTPClient{}, 198 | results: failedResults, 199 | failedCount: 1, 200 | notifySuccess: false, 201 | headerName: headerName, 202 | headerValue: headerValue, 203 | verbose: true, 204 | wantErr: true, 205 | }, 206 | } 207 | 208 | for _, tt := range tests { 209 | t.Run(tt.name, func(t *testing.T) { 210 | cfg := newConfig(tt.headerName, tt.headerValue, tt.notifySuccess, tt.verbose) 211 | sender, err := NewAPISender(tt.client, cfg, &MockLogger{}) 212 | if err != nil { 213 | t.Fatalf("failed to create API sender instance: %v", err) 214 | } 215 | 216 | err = sender.send(&tt.results, tt.failedCount) 217 | if tt.wantErr != (err != nil) { 218 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 219 | } 220 | }) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /pkg/alert/pushgateway_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | ) 9 | 10 | const pushgatewayURL = "https://abc123.xyz.com" 11 | 12 | func TestNewPushgatewaySender(t *testing.T) { 13 | mockHTTPClient := &SuccessStatusHTTPClient{} 14 | mockLogger := &MockLogger{} 15 | 16 | instanceName := "test-instance" 17 | verbose := false 18 | notifySuccess := false 19 | 20 | expected := &pushgatewaySender{ 21 | httpClient: mockHTTPClient, 22 | url: pushgatewayURL, 23 | instanceName: "test-instance", 24 | notifySuccess: notifySuccess, 25 | verbose: verbose, 26 | logger: mockLogger, 27 | // template will be compared based on its output, no need for it here 28 | } 29 | 30 | cfg := &config.Config{ 31 | PushgatewayURL: pushgatewayURL, 32 | InstanceName: instanceName, 33 | Verbose: verbose, 34 | MachineNotifySuccess: notifySuccess, 35 | } 36 | 37 | actual, err := NewPushgatewaySender(mockHTTPClient, cfg, mockLogger) 38 | if err != nil { 39 | t.Fatalf("error creating a new Pushgateway sender instance: %v", err) 40 | } 41 | 42 | // Compare fields individually due to complex structs 43 | if expected.url != actual.url { 44 | t.Errorf("expected url: %s, got: %s", expected.url, actual.url) 45 | } 46 | if expected.instanceName != actual.instanceName { 47 | t.Errorf("expected instanceName: %s, got: %s", expected.instanceName, actual.instanceName) 48 | } 49 | if expected.verbose != actual.verbose { 50 | t.Errorf("expected verbose: %v, got: %v", expected.verbose, actual.verbose) 51 | } 52 | if expected.notifySuccess != actual.notifySuccess { 53 | t.Errorf("expected notifySuccess: %v, got: %v", expected.notifySuccess, actual.notifySuccess) 54 | } 55 | if fmt.Sprintf("%T", expected.httpClient) != fmt.Sprintf("%T", actual.httpClient) { 56 | t.Errorf("expected httpClient type: %T, got: %T", expected.httpClient, actual.httpClient) 57 | } 58 | if fmt.Sprintf("%T", expected.logger) != fmt.Sprintf("%T", actual.logger) { 59 | t.Errorf("expected logger type: %T, got: %T", expected.logger, actual.logger) 60 | } 61 | } 62 | 63 | func TestSend_Pushgateway(t *testing.T) { 64 | instanceName := "test-instance" 65 | 66 | successResults := Results{ 67 | Map: map[string]bool{ 68 | "test": true, 69 | }, 70 | } 71 | failedResults := Results{ 72 | Map: map[string]bool{ 73 | "test": false, 74 | }, 75 | } 76 | mixedResults := Results{ 77 | Map: map[string]bool{ 78 | "test1": true, 79 | "test2": false, 80 | }, 81 | } 82 | 83 | newConfig := func(url, instanceName string, notifySuccess, verbose bool) *config.Config { 84 | return &config.Config{ 85 | PushgatewayURL: url, 86 | InstanceName: instanceName, 87 | Verbose: verbose, 88 | MachineNotifySuccess: notifySuccess, 89 | } 90 | } 91 | 92 | tests := []struct { 93 | name string 94 | client HTTPClient 95 | results Results 96 | failedCount int 97 | instanceName string 98 | notifySuccess bool 99 | verbose bool 100 | wantErr bool 101 | }{ 102 | { 103 | name: "Failed Sockets", 104 | client: &SuccessStatusHTTPClient{}, 105 | results: failedResults, 106 | failedCount: 1, 107 | instanceName: instanceName, 108 | notifySuccess: false, 109 | verbose: false, 110 | wantErr: false, 111 | }, 112 | { 113 | name: "Failed Sockets - Verbose", 114 | client: &SuccessStatusHTTPClient{}, 115 | results: failedResults, 116 | failedCount: 1, 117 | instanceName: instanceName, 118 | notifySuccess: false, 119 | verbose: true, 120 | wantErr: false, 121 | }, 122 | { 123 | name: "No Failed Sockets With notifySuccess", 124 | client: &SuccessStatusHTTPClient{}, 125 | results: successResults, 126 | failedCount: 0, 127 | instanceName: instanceName, 128 | notifySuccess: true, 129 | verbose: false, 130 | wantErr: false, 131 | }, 132 | { 133 | name: "No Failed Sockets Without notifySuccess", 134 | client: &SuccessStatusHTTPClient{}, 135 | results: successResults, 136 | failedCount: 0, 137 | instanceName: instanceName, 138 | notifySuccess: false, 139 | verbose: false, 140 | wantErr: false, 141 | }, 142 | { 143 | name: "No Failed Sockets Without notifySuccess - Verbose", 144 | client: &SuccessStatusHTTPClient{}, 145 | results: successResults, 146 | failedCount: 0, 147 | instanceName: instanceName, 148 | notifySuccess: false, 149 | verbose: true, 150 | wantErr: false, 151 | }, 152 | { 153 | name: "Mixed Results With notifySuccess", 154 | client: &SuccessStatusHTTPClient{}, 155 | results: mixedResults, 156 | failedCount: 1, 157 | instanceName: instanceName, 158 | notifySuccess: true, 159 | verbose: false, 160 | wantErr: false, 161 | }, 162 | { 163 | name: "Mixed Results Without notifySuccess", 164 | client: &SuccessStatusHTTPClient{}, 165 | results: mixedResults, 166 | failedCount: 1, 167 | instanceName: instanceName, 168 | notifySuccess: false, 169 | verbose: false, 170 | wantErr: false, 171 | }, 172 | { 173 | name: "Empty Instance Name", 174 | client: &SuccessStatusHTTPClient{}, 175 | results: failedResults, 176 | failedCount: 1, 177 | instanceName: "", 178 | notifySuccess: false, 179 | verbose: false, 180 | wantErr: false, 181 | }, 182 | { 183 | name: "Network Error When Pushing to Pushgateway", 184 | client: &FailureHTTPClient{}, 185 | results: failedResults, 186 | failedCount: 1, 187 | instanceName: instanceName, 188 | notifySuccess: false, 189 | verbose: false, 190 | wantErr: true, 191 | }, 192 | { 193 | name: "Unexpected Response Code From Pushgateway", 194 | client: &ErrorStatusHTTPClient{}, 195 | results: failedResults, 196 | failedCount: 1, 197 | instanceName: instanceName, 198 | notifySuccess: false, 199 | verbose: false, 200 | wantErr: true, 201 | }, 202 | { 203 | name: "Error Reading Response Body From Pushgateway", 204 | client: &InvalidResponseBodyHTTPClient{}, 205 | results: failedResults, 206 | failedCount: 1, 207 | instanceName: instanceName, 208 | notifySuccess: false, 209 | verbose: true, 210 | wantErr: true, 211 | }, 212 | } 213 | 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | cfg := newConfig(pushgatewayURL, tt.instanceName, tt.notifySuccess, tt.verbose) 217 | sender, err := NewPushgatewaySender(tt.client, cfg, &MockLogger{}) 218 | if err != nil { 219 | t.Fatalf("failed to create Pushgateway sender instance: %v", err) 220 | } 221 | 222 | err = sender.send(&tt.results, tt.failedCount) 223 | if tt.wantErr != (err != nil) { 224 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 225 | } 226 | }) 227 | } 228 | } 229 | 230 | func TestCreateMessage(t *testing.T) { 231 | cfg := &config.Config{ 232 | PushgatewayURL: pushgatewayURL, 233 | InstanceName: "test-instance", 234 | MachineNotifySuccess: false, 235 | Verbose: false, 236 | } 237 | 238 | sender, err := NewPushgatewaySender(&SuccessStatusHTTPClient{}, cfg, &MockLogger{}) 239 | if err != nil { 240 | t.Fatalf("failed to create Pushgateway sender instance: %v", err) 241 | } 242 | 243 | failedCount := 1 244 | 245 | expected := ` 246 | #HELP failed sockets registered by dish 247 | #TYPE dish_failed_count counter 248 | dish_failed_count 1 249 | 250 | ` 251 | 252 | actual, err := sender.createMessage(failedCount) 253 | if err != nil { 254 | t.Errorf("error creating Pushgateway message: %v", err) 255 | } 256 | 257 | if expected != actual { 258 | t.Errorf("expected %s, got %s", expected, actual) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /pkg/alert/notifier_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "testing" 7 | 8 | "go.vxn.dev/dish/pkg/config" 9 | ) 10 | 11 | const ( 12 | badURL = "0řžuničx://č/tmpě/test.hook\x09" 13 | ) 14 | 15 | func TestNewNotifier_Nil(t *testing.T) { 16 | var ( 17 | configBlank = &config.Config{} 18 | mockLogger = &MockLogger{} 19 | successStatusHTTPClient = SuccessStatusHTTPClient{} 20 | ) 21 | 22 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 23 | 24 | if notifier := NewNotifier(nil, nil, nil); notifier != nil { 25 | t.Error("unexpected behaviour, should be nil") 26 | } 27 | 28 | if notifier := NewNotifier(nil, configBlank, nil); notifier != nil { 29 | t.Error("unexpected behaviour, should be nil") 30 | } 31 | 32 | if notifier := NewNotifier(&successStatusHTTPClient, configBlank, nil); notifier != nil { 33 | t.Error("expected nil, got notifier (nil logger)") 34 | } 35 | 36 | if notifier := NewNotifier(&successStatusHTTPClient, nil, mockLogger); notifier != nil { 37 | t.Error("expected nil, got notifier (nil config)") 38 | } 39 | 40 | if notifier := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger); notifier == nil { 41 | t.Error("unexpected nil on output") 42 | } 43 | } 44 | 45 | func TestNewNotifier_Telegram(t *testing.T) { 46 | var ( 47 | mockLogger = &MockLogger{} 48 | successStatusHTTPClient = SuccessStatusHTTPClient{} 49 | ) 50 | 51 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 52 | configDefault.TelegramBotToken = "abc:2025062700" 53 | configDefault.TelegramChatID = "-10987654321" 54 | 55 | notifierTelegram := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 56 | if notifierTelegram == nil { 57 | t.Fatal("unexpected nil on output (Telegram*)") 58 | } 59 | 60 | if notifiersLen := len(notifierTelegram.chatNotifiers); notifiersLen == 0 { 61 | t.Errorf("expected 1 chatNotifier: got %d", notifiersLen) 62 | } 63 | } 64 | 65 | func TestNewNotifier_API(t *testing.T) { 66 | var ( 67 | mockLogger = &MockLogger{} 68 | successStatusHTTPClient = SuccessStatusHTTPClient{} 69 | ) 70 | 71 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 72 | configDefault.ApiURL = "https://api.example.com/?test=true" 73 | 74 | notifierAPI := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 75 | if notifierAPI == nil { 76 | t.Fatal("unexpected nil on output (ApiURL)") 77 | } 78 | 79 | if len(notifierAPI.machineNotifiers) != 1 { 80 | t.Errorf("expected 1 machineNotifier, got %d", len(notifierAPI.machineNotifiers)) 81 | } 82 | 83 | configDefault.ApiURL = badURL 84 | 85 | notifierAPI = NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 86 | if notifierAPI == nil { 87 | t.Fatal("unexpected nil on output (ApiURL)") 88 | } 89 | 90 | if len(notifierAPI.machineNotifiers) != 0 { 91 | t.Errorf("expected 0 machineNotifiers, got %d", len(notifierAPI.machineNotifiers)) 92 | } 93 | } 94 | 95 | func TestNewNotifier_Webhook(t *testing.T) { 96 | var ( 97 | mockLogger = &MockLogger{} 98 | successStatusHTTPClient = SuccessStatusHTTPClient{} 99 | ) 100 | 101 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 102 | configDefault.WebhookURL = "https://www.example.com/hooks/test-hook" 103 | 104 | notifierWebhook := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 105 | if notifierWebhook == nil { 106 | t.Fatal("unexpected nil on output (Webhooks)") 107 | } 108 | 109 | if len(notifierWebhook.machineNotifiers) != 1 { 110 | t.Errorf("expected 1 machineNotifier, got %d", len(notifierWebhook.machineNotifiers)) 111 | } 112 | 113 | configDefault.WebhookURL = badURL 114 | 115 | notifierWebhook = NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 116 | if notifierWebhook == nil { 117 | t.Fatal("unexpected nil on output (Webhooks)") 118 | } 119 | 120 | if len(notifierWebhook.machineNotifiers) != 0 { 121 | t.Errorf("expected 0 machineNotifiers, got %d", len(notifierWebhook.machineNotifiers)) 122 | } 123 | } 124 | 125 | func TestNewNotifier_Pushgateawy(t *testing.T) { 126 | var ( 127 | mockLogger = &MockLogger{} 128 | successStatusHTTPClient = SuccessStatusHTTPClient{} 129 | ) 130 | 131 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 132 | configDefault.PushgatewayURL = "https://pgw.example.com/push/" 133 | 134 | notifierPushgateway := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 135 | if notifierPushgateway == nil { 136 | t.Fatal("unexpected nil on output (Pushgateway)") 137 | } 138 | 139 | if len(notifierPushgateway.machineNotifiers) != 1 { 140 | t.Errorf("expected 1 machineNotifier, got %d", len(notifierPushgateway.machineNotifiers)) 141 | } 142 | 143 | configDefault.PushgatewayURL = badURL 144 | 145 | notifierPushgateway = NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 146 | if notifierPushgateway == nil { 147 | t.Fatal("unexpected nil on output (Pushgateway)") 148 | } 149 | 150 | if len(notifierPushgateway.machineNotifiers) != 0 { 151 | t.Errorf("expected 0 machineNotifiers, got %d", len(notifierPushgateway.machineNotifiers)) 152 | } 153 | } 154 | 155 | func TestNewNotifier_Discord(t *testing.T) { 156 | var ( 157 | mockLogger = &MockLogger{} 158 | successStatusHTTPClient = SuccessStatusHTTPClient{} 159 | ) 160 | 161 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 162 | configDefault.DiscordBotToken = "test" 163 | configDefault.DiscordChannelID = "-123" 164 | 165 | notifierDiscord := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 166 | if notifierDiscord == nil { 167 | t.Fatal("unexpected nil on output (Discord*)") 168 | } 169 | 170 | if notifiersLen := len(notifierDiscord.chatNotifiers); notifiersLen == 0 { 171 | t.Errorf("expected 1 chatNotifier: got %d", notifiersLen) 172 | } 173 | } 174 | 175 | func TestSendChatNotifications(t *testing.T) { 176 | var ( 177 | mockLogger = &MockLogger{} 178 | successStatusHTTPClient = SuccessStatusHTTPClient{} 179 | ) 180 | 181 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 182 | configDefault.TelegramBotToken = "" 183 | configDefault.TelegramChatID = "" 184 | 185 | notifierTelegram := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 186 | fmt.Println(notifierTelegram.chatNotifiers) 187 | if len(notifierTelegram.chatNotifiers) > 0 { 188 | t.Errorf("expected 0 chatNotifiers, got %d", len(notifierTelegram.chatNotifiers)) 189 | } 190 | 191 | if err := notifierTelegram.SendChatNotifications("SendChatNotifications test", 0); err != nil { 192 | t.Error("unexpected error: ", err) 193 | } 194 | 195 | configDefault.TelegramBotToken = "abc:2025062700" 196 | configDefault.TelegramChatID = "-10987654321" 197 | 198 | notifierTelegram = NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 199 | if notifierTelegram == nil { 200 | t.Fatal("unexpected nil on output") 201 | } 202 | 203 | if err := notifierTelegram.SendChatNotifications("SendChatNotifications test", 0); err != nil { 204 | t.Error("unexpected error: ", err) 205 | } 206 | 207 | mockTelegram := telegramSender{httpClient: &successStatusHTTPClient, logger: mockLogger, token: "$á+\x00"} 208 | notifierTelegram.chatNotifiers[0] = &mockTelegram 209 | 210 | if err := notifierTelegram.SendChatNotifications("", 20); err == nil { 211 | t.Error("expected error, got nil") 212 | } 213 | } 214 | 215 | func TestSendMachineNotifications(t *testing.T) { 216 | var ( 217 | mockLogger = &MockLogger{} 218 | successStatusHTTPClient = SuccessStatusHTTPClient{} 219 | ) 220 | configDefault, _ := config.NewConfig(flag.NewFlagSet("test", flag.ContinueOnError), []string{""}) 221 | configDefault.WebhookURL = "" 222 | 223 | notifierWebhook := NewNotifier(&successStatusHTTPClient, configDefault, mockLogger) 224 | if notifierWebhook == nil { 225 | t.Fatal("unexpected nil on output (Webhooks)") 226 | } 227 | 228 | if err := notifierWebhook.SendMachineNotifications(nil, 0); err != nil { 229 | t.Error("unexpected error: ", err) 230 | } 231 | 232 | configDefault.WebhookURL = "https://www.example.com/hooks/test-hook" 233 | 234 | notifierWebhook = NewNotifier(nil, configDefault, mockLogger) 235 | if notifierWebhook == nil { 236 | t.Fatal("unexpected nil on output (Webhooks)") 237 | } 238 | 239 | if err := notifierWebhook.SendMachineNotifications(nil, 0); err != nil { 240 | t.Error("unexpected error: ", err) 241 | } 242 | 243 | mockWebhook := webhookSender{httpClient: nil, url: badURL, logger: mockLogger} 244 | notifierWebhook.machineNotifiers[0] = &mockWebhook 245 | 246 | if err := notifierWebhook.SendMachineNotifications(nil, 20); err == nil { 247 | t.Error("expected error, got nil") 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | dish_logo 3 | dish 4 |

5 | 6 | [![PkgGoDev](https://pkg.go.dev/badge/go.vxn.dev/dish)](https://pkg.go.dev/go.vxn.dev/dish) 7 | [![Go Report Card](https://goreportcard.com/badge/go.vxn.dev/dish)](https://goreportcard.com/report/go.vxn.dev/dish) 8 | [![Go Coverage](https://github.com/thevxn/dish/wiki/coverage.svg)](https://raw.githack.com/wiki/thevxn/dish/coverage.html) 9 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 10 | [![libs.tech recommends](https://libs.tech/project/468033120/badge.svg)](https://libs.tech/project/468033120/dish) 11 | 12 | + __Tiny__ one-shot monitoring service 13 | + __Remote__ or local configuration 14 | + __Fast__ concurrent testing, low overall execution time 15 | + __Zero__ dependencies 16 | 17 | ## Use Cases 18 | 19 | + Lightweight health checks of HTTP and ICMP endpoints and TCP sockets 20 | + Decentralized monitoring with standalone dish instances deployed on different hosts that pull configuration from a common API 21 | + Cron-driven one-shot checks without the need for any long-running agents 22 | 23 | ## Install 24 | 25 | ### Using go install 26 | 27 | ```shell 28 | go install go.vxn.dev/dish/cmd/dish@latest 29 | ``` 30 | 31 | ### Using Homebrew 32 | 33 | ```shell 34 | brew install dish 35 | ``` 36 | 37 | ### Manual Download 38 | 39 | Download the binary built for your OS and architecture from the [Releases](https://github.com/thevxn/dish/releases) section. 40 | 41 | ## Usage 42 | 43 | ``` 44 | dish [FLAGS] SOURCE 45 | ``` 46 | 47 | ![dish run](.github/dish_run.png) 48 | 49 | ### Source 50 | 51 | The list of endpoints to be checked can be provided in 2 ways: 52 | 53 | 1. A local JSON file 54 | + E.g., the `./configs/demo_sockets.json` file included in this repository as an example. 55 | 2. A remote RESTful JSON API endpoint 56 | + The list of sockets retrieved from this endpoint can also be locally cached (see the `-cache`, `-cacheDir` and `-cacheTTL` flags below). 57 | + Using local cache prevents constant hitting of the endpoint when running checks in short, periodic intervals. It also enables dish to run its checks even if the remote endpoint is down using the cached list of sockets (if available), even when the cache is considered expired. 58 | 59 | For the expected JSON schema of the list of sockets to be checked, see `./configs/demo_sockets.json`. 60 | 61 | ```bash 62 | # local JSON file 63 | dish /opt/dish/sockets.json 64 | 65 | # remote JSON API source 66 | dish http://restapi.example.com/dish/sockets/:instance 67 | ``` 68 | 69 | ### Specifying Protocol 70 | 71 | The protocol which `dish` will use to check the provided endpoint will be determined by using the following rules (first matching rule applies) on the provided config JSON: 72 | 73 | + If the `host_name` field starts with "http://" or "https://", __HTTP__ will be used. 74 | + If the `port_tcp` field is between 1 and 65535, __TCP__ will be used. 75 | + If `host_name` is not empty, __ICMP__ will be used. 76 | + If none of the above conditions are met, the check fails. 77 | 78 | __Note:__ ICMP is currently not supported on Windows. 79 | 80 | ### Flags 81 | 82 | ``` 83 | dish -h 84 | Usage of dish: 85 | -cache 86 | a bool, specifies whether to cache the socket list fetched from the remote API source 87 | -cacheDir string 88 | a string, specifies the directory used to cache the socket list fetched from the remote API source (default ".cache") 89 | -cacheTTL uint 90 | an int, time duration (in minutes) for which the cached list of sockets is valid (default 10) 91 | -discordBotToken string 92 | a string, Discord bot token 93 | -discordChannelId string 94 | a string, Discord channel ID 95 | -hname string 96 | a string, name of a custom additional header to be used when fetching and pushing results to the remote API (used mainly for auth purposes) 97 | -hvalue string 98 | a string, value of the custom additional header to be used when fetching and pushing results to the remote API (used mainly for auth purposes) 99 | -machineNotifySuccess 100 | a bool, specifies whether successful checks with no failures should be reported to machine channels 101 | -name string 102 | a string, dish instance name (default "generic-dish") 103 | -target string 104 | a string, result update path/URL to pushgateway, plaintext/byte output 105 | -telegramBotToken string 106 | a string, Telegram bot private token 107 | -telegramChatID string 108 | a string, Telegram chat/channel ID 109 | -textNotifySuccess 110 | a bool, specifies whether successful checks with no failures should be reported to text channels 111 | -timeout uint 112 | an int, timeout in seconds for http and tcp calls (default 10) 113 | -updateURL string 114 | a string, API endpoint URL for pushing results 115 | -verbose 116 | a bool, console stdout logging toggle, output is colored unless disabled by NO_COLOR=true environment variable 117 | -webhookURL string 118 | a string, URL of webhook endpoint 119 | ``` 120 | 121 | ### Exit Codes 122 | 123 | `dish` exits with specific codes to signal the result of its checks or the nature of an internal failure. 124 | 125 | | Exit Code | Meaning | 126 | |-----------|-----------------------------------------| 127 | | 0 | All checks passed successfully | 128 | | 1 | No socket source provided | 129 | | 2 | Failed to parse command-line arguments | 130 | | 3 | Failed to run tests on sockets | 131 | | 4 | Failed to reach one or more sockets | 132 | 133 | ### Alerting 134 | 135 | When a socket test fails, it's always good to be notified. For this purpose, dish provides 5 different ways of doing so (can be combined): 136 | 137 | + Test results upload to a remote JSON API (using the `-updateURL` flag) 138 | + Check results as the Telegram message body (via the `-telegramBotToken` and `-telegramChatID` flags) 139 | + Failed count and last test timestamp update to Pushgateway for Prometheus (using the `-target` flag) 140 | + Test results push to a webhook URL (using the `-webhookURL` flag) 141 | + Check results as a Discord message (via the `-discordBotToken` and `-discordChannelId` flags) 142 | 143 | Whether successful runs with no failed checks should be reported can also be configured using flags: 144 | 145 | + `-textNotifySuccess` for text channels (e.g. Telegram, Discord) 146 | + `-machineNotifySuccess` for machine channels (e.g. webhooks, remote API or Pushgateway) 147 | 148 | ![telegram-alerting](/.github/dish_telegram.png) 149 | 150 | (The screenshot above shows Telegram alerting as of `v1.10.0`. The screenshot shows the result of using the `-textNotifySuccess` flag to include successful checks in the alert as well.) 151 | 152 | ### Examples 153 | 154 | One way to run dish is to build and install a binary executable. 155 | 156 | ```shell 157 | # Fetch and install the specific version 158 | go install go.vxn.dev/dish/cmd/dish@latest 159 | 160 | export PATH=$PATH:~/go/bin 161 | 162 | # Load sockets from sockets.json file, and use Telegram 163 | # provider for alerting 164 | dish -telegramChatID "-123456789" \ 165 | -telegramBotToken "123:AAAbcD_ef" \ 166 | sockets.json 167 | 168 | # Use remote JSON API service as socket source, and push 169 | # the results to Pushgateway 170 | dish -target https://pushgw.example.com/ \ 171 | https://api.example.com/dish/sockets 172 | ``` 173 | 174 | #### Using Docker 175 | 176 | ```shell 177 | # Copy, and/or edit dot-env file (optional) 178 | cp .env.example .env 179 | vi .env 180 | 181 | # Build a Docker image 182 | make build 183 | 184 | # Run using docker compose stack 185 | make run 186 | 187 | # Run using native docker run 188 | docker run --rm \ 189 | dish:1.12.0-go1.24 \ 190 | -verbose \ 191 | -target https://pushgateway.example.com \ 192 | https://api.example.com 193 | ``` 194 | 195 | #### Bash script and Cronjob 196 | 197 | Create a bash script to easily deploy dish and update its settings: 198 | 199 | ```shell 200 | vi tiny-dish-run.sh 201 | ``` 202 | 203 | ```shell 204 | #!/bin/bash 205 | 206 | TELEGRAM_TOKEN="123:AAAbcD_ef" 207 | TELEGRAM_CHATID="-123456789" 208 | 209 | SOURCE_URL=https://api.example.com/dish/sockets 210 | UPDATE_URL=https://api.example.com/dish/sockets/results 211 | TARGET_URL=https://pushgw.example.com 212 | 213 | DISH_TAG=dish:1.12.0-go1.24 214 | INSTANCE_NAME=tiny-dish 215 | 216 | API_TOKEN=AbCd 217 | 218 | docker run --rm \ 219 | ${DISH_TAG} \ 220 | -name ${INSTANCE_NAME} \ 221 | -hvalue ${API_TOKEN} \ 222 | -hname X-Auth-Token \ 223 | -target ${TARGET_URL} \ 224 | -updateURL ${UPDATE_URL} \ 225 | -telegramBotToken ${TELEGRAM_TOKEN} \ 226 | -telegramChatID ${TELEGRAM_CHATID} \ 227 | -timeout 15 \ 228 | -verbose \ 229 | ${SOURCE_URL} 230 | ``` 231 | 232 | Make it an executable: 233 | 234 | ```shell 235 | chmod +x tiny-dish-run.sh 236 | ``` 237 | 238 | ##### Cronjob to run periodically 239 | 240 | ```shell 241 | crontab -e 242 | ``` 243 | 244 | ```shell 245 | # m h dom mon dow command 246 | MAILTO=monitoring@example.com 247 | 248 | */2 * * * * /home/user/tiny-dish-run.sh 249 | ``` 250 | 251 | ### Integration Example 252 | 253 | For an example of what can be built using dish integrated with a remote API, you can check out our [status page](https://status.vxn.dev). 254 | 255 | ## Contributing 256 | 257 | dish is a project of a relatively small scale which we believe makes it interesting whether you are a beginner or an experienced developer. 258 | 259 | We are beginner-friendly, therefore, if you are willing to spend the time getting to know the codebase and possibly Go (depending on your experience level), we are happy to provide feedback and guidance. 260 | 261 | Feel free to check out the Issues section for a list of things we could use your help with. You can also create an issue yourself if you have a proposal for extending dish's functionality or have found a bug. 262 | 263 | ## Articles 264 | 265 | + [dish deep-dive article](https://blog.vxn.dev/dish-monitoring-service) 266 | + [dish history article](https://krusty.space/projects/dish/) 267 | -------------------------------------------------------------------------------- /pkg/logger/console_logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestNewConsoleLogger(t *testing.T) { 11 | t.Run("verbose mode on", func(t *testing.T) { 12 | logger := NewConsoleLogger(true, nil) 13 | if logger.logLevel != TRACE { 14 | t.Errorf("expected loglevel %d, got %d", TRACE, logger.logLevel) 15 | } 16 | }) 17 | 18 | t.Run("verbose mode off", func(t *testing.T) { 19 | logger := NewConsoleLogger(false, nil) 20 | if logger.logLevel != INFO { 21 | t.Errorf("expected loglevel %d, got %d", INFO, logger.logLevel) 22 | } 23 | }) 24 | 25 | t.Run("default out", func(t *testing.T) { 26 | origStderr := os.Stderr 27 | defer func() { os.Stderr = origStderr }() 28 | 29 | r, w, _ := os.Pipe() 30 | os.Stderr = w 31 | 32 | logger := &consoleLogger{ 33 | stdLogger: log.New(w, "", 0), 34 | logLevel: TRACE, 35 | } 36 | logger.Info("hello stderr") 37 | 38 | if err := w.Close(); err != nil { 39 | t.Errorf("failed to close capture pipe writer: %v", err) 40 | } 41 | 42 | var buf bytes.Buffer 43 | _, err := buf.ReadFrom(r) 44 | if err != nil { 45 | t.Fatalf("failed to read from pipe: %v", err) 46 | } 47 | 48 | expected := INFO.Prefix(false) + "hello stderr\n" 49 | actual := buf.String() 50 | if actual != expected { 51 | t.Fatalf("expected %q in stderr, got %q", expected, actual) 52 | } 53 | }) 54 | 55 | t.Run("provided out", func(t *testing.T) { 56 | var buf bytes.Buffer 57 | 58 | logger := &consoleLogger{ 59 | stdLogger: log.New(&buf, "", 0), 60 | logLevel: TRACE, 61 | } 62 | 63 | logger.Info("output test") 64 | 65 | expected := INFO.Prefix(false) + "output test\n" 66 | actual := buf.String() 67 | 68 | if actual != expected { 69 | t.Fatalf("expected %s, got %s", expected, actual) 70 | } 71 | }) 72 | 73 | t.Run("with colors when verbose and no env set", func(t *testing.T) { 74 | logger := NewConsoleLogger(true, nil) 75 | if !logger.withColors { 76 | t.Error("expected logger to have colors enabled") 77 | } 78 | }) 79 | 80 | t.Run("without colors when env is set", func(t *testing.T) { 81 | _ = os.Setenv("NO_COLOR", "true") 82 | logger := NewConsoleLogger(true, nil) 83 | if logger.withColors { 84 | t.Error("expected logger to have colors disabled") 85 | } 86 | _ = os.Setenv("NO_COLOR", "false") 87 | }) 88 | 89 | t.Run("without colors when not verbose is not set", func(t *testing.T) { 90 | logger := NewConsoleLogger(false, nil) 91 | if logger.withColors { 92 | t.Error("expected logger to have colors disabled") 93 | } 94 | }) 95 | } 96 | 97 | func TestConsoleLogger_log(t *testing.T) { 98 | var buf bytes.Buffer 99 | 100 | tests := []struct { 101 | name string 102 | logFunc func(*consoleLogger) 103 | logger *consoleLogger 104 | expected string 105 | }{ 106 | { 107 | name: "Info adds INFO prefix and joins arguments with spaces", 108 | logFunc: func(logger *consoleLogger) { 109 | logger.Info("hello", 123, 321) 110 | }, 111 | logger: &consoleLogger{ 112 | stdLogger: log.New(&buf, "", 0), 113 | logLevel: TRACE, 114 | }, 115 | expected: INFO.Prefix(false) + "hello123 321\n", 116 | }, 117 | { 118 | name: "Info adds INFO prefix with colors", 119 | logFunc: func(logger *consoleLogger) { 120 | logger.Info("hello") 121 | }, 122 | logger: &consoleLogger{ 123 | stdLogger: log.New(&buf, "", 0), 124 | logLevel: TRACE, 125 | withColors: true, 126 | }, 127 | expected: INFO.Prefix(true) + "hello\n", 128 | }, 129 | { 130 | name: "Infof adds INFO prefix and formats string correctly", 131 | logFunc: func(logger *consoleLogger) { 132 | logger.Infof("hello %s !", "dish") 133 | }, 134 | logger: &consoleLogger{ 135 | stdLogger: log.New(&buf, "", 0), 136 | logLevel: TRACE, 137 | }, 138 | expected: INFO.Prefix(false) + "hello dish !\n", 139 | }, 140 | { 141 | name: "Infof adds INFO prefix with color", 142 | logFunc: func(logger *consoleLogger) { 143 | logger.Infof("hello %s !", "dish") 144 | }, 145 | logger: &consoleLogger{ 146 | stdLogger: log.New(&buf, "", 0), 147 | logLevel: TRACE, 148 | withColors: true, 149 | }, 150 | expected: INFO.Prefix(true) + "hello dish !\n", 151 | }, 152 | { 153 | name: "Debug does not print if logLevel is INFO", 154 | logFunc: func(logger *consoleLogger) { 155 | logger.Debug("should not print") 156 | }, 157 | logger: &consoleLogger{ 158 | stdLogger: log.New(&buf, "", 0), 159 | logLevel: INFO, 160 | }, 161 | expected: "", 162 | }, 163 | { 164 | name: "Debug adds DEBUG prefix", 165 | logFunc: func(logger *consoleLogger) { 166 | logger.Debug("debug") 167 | }, 168 | logger: &consoleLogger{ 169 | stdLogger: log.New(&buf, "", 0), 170 | logLevel: DEBUG, 171 | }, 172 | expected: DEBUG.Prefix(false) + "debug\n", 173 | }, 174 | { 175 | name: "Debug adds DEBUG prefix with color", 176 | logFunc: func(logger *consoleLogger) { 177 | logger.Debug("debug") 178 | }, 179 | logger: &consoleLogger{ 180 | stdLogger: log.New(&buf, "", 0), 181 | logLevel: DEBUG, 182 | withColors: true, 183 | }, 184 | expected: DEBUG.Prefix(true) + "debug\n", 185 | }, 186 | { 187 | name: "Debugf adds DEBUG prefix and formats string correctly", 188 | logFunc: func(logger *consoleLogger) { 189 | logger.Debugf("debug %d", 1) 190 | }, 191 | logger: &consoleLogger{ 192 | stdLogger: log.New(&buf, "", 0), 193 | logLevel: DEBUG, 194 | }, 195 | expected: DEBUG.Prefix(false) + "debug 1\n", 196 | }, 197 | { 198 | name: "Debugf adds DEBUG prefix with color and formats string correctly", 199 | logFunc: func(logger *consoleLogger) { 200 | logger.Debugf("debug %d", 1) 201 | }, 202 | logger: &consoleLogger{ 203 | stdLogger: log.New(&buf, "", 0), 204 | logLevel: DEBUG, 205 | withColors: true, 206 | }, 207 | expected: DEBUG.Prefix(true) + "debug 1\n", 208 | }, 209 | { 210 | name: "Warn prints with WARN prefix", 211 | logFunc: func(logger *consoleLogger) { 212 | logger.Warn("warn message") 213 | }, 214 | logger: &consoleLogger{ 215 | stdLogger: log.New(&buf, "", 0), 216 | logLevel: TRACE, 217 | }, 218 | expected: WARN.Prefix(false) + "warn message\n", 219 | }, 220 | { 221 | name: "Warn prints with WARN prefix with color", 222 | logFunc: func(logger *consoleLogger) { 223 | logger.Warn("warn message") 224 | }, 225 | logger: &consoleLogger{ 226 | stdLogger: log.New(&buf, "", 0), 227 | logLevel: TRACE, 228 | withColors: true, 229 | }, 230 | expected: WARN.Prefix(true) + "warn message\n", 231 | }, 232 | { 233 | name: "Warnf prints formatted WARN message", 234 | logFunc: func(logger *consoleLogger) { 235 | logger.Warnf("warn %d", 42) 236 | }, 237 | logger: &consoleLogger{ 238 | stdLogger: log.New(&buf, "", 0), 239 | logLevel: TRACE, 240 | }, 241 | expected: WARN.Prefix(false) + "warn 42\n", 242 | }, 243 | { 244 | name: "Warnf prints formatted WARN message with color", 245 | logFunc: func(logger *consoleLogger) { 246 | logger.Warnf("warn %d", 42) 247 | }, 248 | logger: &consoleLogger{ 249 | stdLogger: log.New(&buf, "", 0), 250 | logLevel: TRACE, 251 | withColors: true, 252 | }, 253 | expected: WARN.Prefix(true) + "warn 42\n", 254 | }, 255 | { 256 | name: "Error prints with ERROR prefix", 257 | logFunc: func(logger *consoleLogger) { 258 | logger.Error("error") 259 | }, 260 | logger: &consoleLogger{ 261 | stdLogger: log.New(&buf, "", 0), 262 | logLevel: TRACE, 263 | }, 264 | expected: ERROR.Prefix(false) + "error\n", 265 | }, 266 | { 267 | name: "Error prints with ERROR prefix with color", 268 | logFunc: func(logger *consoleLogger) { 269 | logger.Error("error") 270 | }, 271 | logger: &consoleLogger{ 272 | stdLogger: log.New(&buf, "", 0), 273 | logLevel: TRACE, 274 | withColors: true, 275 | }, 276 | expected: ERROR.Prefix(true) + "error\n", 277 | }, 278 | { 279 | name: "Errorf prints formatted ERROR message", 280 | logFunc: func(logger *consoleLogger) { 281 | logger.Errorf("fail %s", "here") 282 | }, 283 | logger: &consoleLogger{ 284 | stdLogger: log.New(&buf, "", 0), 285 | logLevel: TRACE, 286 | }, 287 | expected: ERROR.Prefix(false) + "fail here\n", 288 | }, 289 | { 290 | name: "Errorf prints formatted ERROR message with color", 291 | logFunc: func(logger *consoleLogger) { 292 | logger.Errorf("fail %s", "here") 293 | }, 294 | logger: &consoleLogger{ 295 | stdLogger: log.New(&buf, "", 0), 296 | logLevel: TRACE, 297 | withColors: true, 298 | }, 299 | expected: ERROR.Prefix(true) + "fail here\n", 300 | }, 301 | { 302 | name: "Trace prints with TRACE prefix", 303 | logFunc: func(logger *consoleLogger) { 304 | logger.Trace("trace") 305 | }, 306 | logger: &consoleLogger{ 307 | stdLogger: log.New(&buf, "", 0), 308 | logLevel: TRACE, 309 | }, 310 | expected: TRACE.Prefix(false) + "trace\n", 311 | }, 312 | { 313 | name: "Trace prints with TRACE prefix with color", 314 | logFunc: func(logger *consoleLogger) { 315 | logger.Trace("trace") 316 | }, 317 | logger: &consoleLogger{ 318 | stdLogger: log.New(&buf, "", 0), 319 | logLevel: TRACE, 320 | withColors: true, 321 | }, 322 | expected: TRACE.Prefix(true) + "trace\n", 323 | }, 324 | { 325 | name: "Tracef prints formatted TRACE message", 326 | logFunc: func(logger *consoleLogger) { 327 | logger.Tracef("trace %d", 1) 328 | }, 329 | logger: &consoleLogger{ 330 | stdLogger: log.New(&buf, "", 0), 331 | logLevel: TRACE, 332 | }, 333 | expected: TRACE.Prefix(false) + "trace 1\n", 334 | }, 335 | { 336 | name: "Tracef prints formatted TRACE message with color", 337 | logFunc: func(logger *consoleLogger) { 338 | logger.Tracef("trace %d", 1) 339 | }, 340 | logger: &consoleLogger{ 341 | stdLogger: log.New(&buf, "", 0), 342 | logLevel: TRACE, 343 | withColors: true, 344 | }, 345 | expected: TRACE.Prefix(true) + "trace 1\n", 346 | }, 347 | } 348 | 349 | for _, tt := range tests { 350 | buf.Reset() 351 | 352 | tt.logFunc(tt.logger) 353 | 354 | output := buf.String() 355 | 356 | if output != tt.expected { 357 | t.Errorf("expected %s, got %s", tt.expected, output) 358 | } 359 | } 360 | } 361 | 362 | func TestConsoleLogger_log_Panic(t *testing.T) { 363 | logger := NewConsoleLogger(true, nil) 364 | 365 | defer func() { 366 | r := recover() 367 | if r == nil { 368 | t.Fatal("expected panic but did not get one") 369 | } 370 | 371 | expected := PANIC.Prefix(true) + "could not start dish" 372 | if r != expected { 373 | t.Fatalf("expected panic message %s, got %s", expected, r) 374 | } 375 | }() 376 | 377 | logger.Panic("could not start dish") 378 | } 379 | 380 | func TestConsoleLogger_log_Panicf(t *testing.T) { 381 | logger := NewConsoleLogger(true, nil) 382 | 383 | defer func() { 384 | r := recover() 385 | if r == nil { 386 | t.Fatal("expected panic but did not get one") 387 | } 388 | 389 | expected := PANIC.Prefix(true) + "could not start dish" 390 | if r != expected { 391 | t.Fatalf("expected panic message %s, got %s", expected, r) 392 | } 393 | }() 394 | 395 | logger.Panicf("could not start %s", "dish") 396 | } 397 | -------------------------------------------------------------------------------- /pkg/netrunner/runner_test.go: -------------------------------------------------------------------------------- 1 | package netrunner 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "reflect" 8 | "runtime" 9 | "slices" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | "github.com/google/go-cmp/cmp" 15 | "github.com/google/go-cmp/cmp/cmpopts" 16 | "go.vxn.dev/dish/pkg/config" 17 | "go.vxn.dev/dish/pkg/logger" 18 | "go.vxn.dev/dish/pkg/socket" 19 | ) 20 | 21 | const osWindows = "windows" 22 | 23 | // TestChecksum tests the checksum calculation function 24 | func TestChecksum(t *testing.T) { 25 | if runtime.GOOS == osWindows { 26 | expected := 0 27 | actual := checksum([]byte{}) 28 | 29 | if expected != int(actual) { 30 | t.Errorf("unexpected windows checksum. expected: %d, got: %d", expected, actual) 31 | } 32 | return 33 | } 34 | 35 | tests := []struct { 36 | name string 37 | input []byte 38 | expected uint16 39 | }{ 40 | { 41 | name: "empty slice", 42 | input: []byte{}, 43 | expected: 0xFFFF, 44 | }, 45 | { 46 | name: "single byte", 47 | input: []byte{0x45}, 48 | expected: 0xFFBA, 49 | }, 50 | { 51 | name: "two bytes", 52 | input: []byte{0x45, 0x00}, 53 | expected: 0xFFBA, 54 | }, 55 | { 56 | name: "ICMP header example", 57 | input: []byte{0x08, 0x00, 0x00, 0x00, 0x12, 0x34, 0x00, 0x01}, 58 | expected: 0xCAE5, // expected checksum for this header 59 | }, 60 | { 61 | name: "odd length", 62 | input: []byte{0x45, 0x00, 0x1C}, 63 | expected: 0xFF9E, 64 | }, 65 | { 66 | name: "all zeros", 67 | input: []byte{0x00, 0x00, 0x00, 0x00}, 68 | expected: 0xFFFF, 69 | }, 70 | { 71 | name: "all ones", 72 | input: []byte{0xFF, 0xFF, 0xFF, 0xFF}, 73 | expected: 0x0000, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | got := checksum(tt.input) 80 | if got != tt.expected { 81 | t.Errorf("checksum() = 0x%04X, want 0x%04X", got, tt.expected) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | // TestIcmpRunner_RunTest_InputValidation tests input validation edge cases 88 | func TestIcmpRunner_RunTest_InputValidation(t *testing.T) { 89 | if runtime.GOOS == osWindows { 90 | t.Skip("ICMP tests are skipped on Windows") 91 | } 92 | 93 | runner := icmpRunner{ 94 | logger: &MockLogger{}, 95 | } 96 | 97 | tests := []struct { 98 | name string 99 | sock socket.Socket 100 | }{ 101 | { 102 | name: "whitespace only host", 103 | sock: socket.Socket{ 104 | ID: "whitespace_host", 105 | Name: "Whitespace Host", 106 | Host: " \t\n ", 107 | }, 108 | }, 109 | { 110 | name: "host with special characters", 111 | sock: socket.Socket{ 112 | ID: "special_chars_host", 113 | Name: "Special Characters Host", 114 | Host: "test@#$%^&*()host.com", 115 | }, 116 | }, 117 | { 118 | name: "extremely long hostname", 119 | sock: socket.Socket{ 120 | ID: "long_hostname", 121 | Name: "Long Hostname", 122 | Host: "a" + string(make([]byte, 300)) + ".com", 123 | }, 124 | }, 125 | { 126 | name: "hostname with unicode", 127 | sock: socket.Socket{ 128 | ID: "unicode_hostname", 129 | Name: "Unicode Hostname", 130 | Host: "тест.рф", // cyrillic domain 131 | }, 132 | }, 133 | } 134 | 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 138 | defer cancel() 139 | 140 | got := runner.RunTest(ctx, tt.sock) 141 | 142 | if got.Passed { 143 | t.Errorf("expected test to fail for invalid input %s, but it passed", tt.name) 144 | } 145 | 146 | if got.Error == nil { 147 | t.Errorf("expected error for invalid input %s, but got nil", tt.name) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | // TestIcmpRunner_RunTest_IPv4AddressFormats tests various IPv4 address formats 154 | func TestIcmpRunner_RunTest_IPv4AddressFormats(t *testing.T) { 155 | if runtime.GOOS == osWindows { 156 | t.Skip("ICMP tests are skipped on Windows") 157 | } 158 | 159 | runner := icmpRunner{ 160 | logger: &MockLogger{}, 161 | } 162 | 163 | tests := []struct { 164 | name string 165 | host string 166 | shouldPass bool 167 | }{ 168 | { 169 | name: "standard IPv4", 170 | host: "127.0.0.1", 171 | shouldPass: true, 172 | }, 173 | { 174 | name: "IPv4 with leading zeros", 175 | host: "127.000.000.001", 176 | shouldPass: true, // Should resolve to 127.0.0.1 177 | }, 178 | { 179 | name: "invalid IPv4 - too many octets", 180 | host: "127.0.0.1.1", 181 | shouldPass: false, 182 | }, 183 | { 184 | name: "invalid IPv4 - octet out of range", 185 | host: "256.0.0.1", 186 | shouldPass: false, 187 | }, 188 | { 189 | name: "invalid IPv4 - negative octet", 190 | host: "127.0.0.-1", 191 | shouldPass: false, 192 | }, 193 | { 194 | name: "invalid IPv4 - non-numeric", 195 | host: "127.0.0.x", 196 | shouldPass: false, 197 | }, 198 | } 199 | 200 | for _, tt := range tests { 201 | t.Run(tt.name, func(t *testing.T) { 202 | sock := socket.Socket{ 203 | ID: tt.name, 204 | Name: tt.name, 205 | Host: tt.host, 206 | } 207 | 208 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 209 | defer cancel() 210 | 211 | got := runner.RunTest(ctx, sock) 212 | 213 | if tt.shouldPass && !got.Passed { 214 | t.Logf("expected pass but got failure for %s: %v", tt.name, got.Error) 215 | } else if !tt.shouldPass && got.Passed { 216 | t.Errorf("expected failure but got pass for %s", tt.name) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | // TestIcmpRunner_RunTest_DNSResolutionEdgeCases tests DNS resolution edge cases 223 | func TestIcmpRunner_RunTest_DNSResolutionEdgeCases(t *testing.T) { 224 | if runtime.GOOS == osWindows { 225 | t.Skip("ICMP tests are skipped on Windows") 226 | } 227 | 228 | runner := icmpRunner{ 229 | logger: &MockLogger{}, 230 | } 231 | 232 | sock := socket.Socket{ 233 | ID: "ipv6_capable_domain", 234 | Name: "IPv6 Capable Domain", 235 | Host: "ipv6.google.com", 236 | } 237 | 238 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 239 | defer cancel() 240 | 241 | got := runner.RunTest(ctx, sock) 242 | 243 | if !got.Passed { 244 | t.Logf("IPv6 capable domain failed (expected if no IPv4): %v", got.Error) 245 | } 246 | } 247 | 248 | // TestRunSocketTest is an integration test. It executes network calls to 249 | // external public servers. 250 | func TestRunSocketTest(t *testing.T) { 251 | t.Run("output chan is closed and the wait group is not blocking after a successful concurrent test", func(t *testing.T) { 252 | sock := socket.Socket{ 253 | ID: "google_tcp", 254 | Name: "Google TCP", 255 | Host: "google.com", 256 | Port: 80, 257 | } 258 | 259 | want := socket.Result{ 260 | Socket: sock, 261 | Passed: true, 262 | } 263 | 264 | c := make(chan socket.Result) 265 | wg := &sync.WaitGroup{} 266 | cfg, err := config.NewConfig(flag.CommandLine, []string{"--timeout=1", "--verbose=false", "mocksource.json"}) 267 | if err != nil { 268 | t.Fatalf("unexpected error creating config: %v", err) 269 | } 270 | done := make(chan struct{}) 271 | 272 | wg.Add(1) 273 | go RunSocketTest(sock, c, wg, cfg, &MockLogger{}) 274 | 275 | go func() { 276 | wg.Wait() 277 | done <- struct{}{} 278 | }() 279 | 280 | got := <-c 281 | 282 | select { 283 | case <-done: 284 | case <-time.After(time.Second): 285 | t.Fatalf("RunSocketTest: timed out waiting for the test results") 286 | } 287 | 288 | select { 289 | // Once the test is finished no further results are sent. 290 | // If this select case blocks instead of reading the default value immediately then the channel is not closed. 291 | case <-c: 292 | default: 293 | t.Error("RunSocketTest: the output channel has not been closed after returning") 294 | } 295 | 296 | if !cmp.Equal(got, want) { 297 | t.Fatalf("RunSocketTest:\n want = %v\n got = %v\n", want, got) 298 | } 299 | }) 300 | } 301 | 302 | func TestNewNetRunner(t *testing.T) { 303 | type args struct { 304 | sock socket.Socket 305 | logger logger.Logger 306 | } 307 | 308 | tests := []struct { 309 | name string 310 | args args 311 | want NetRunner 312 | wantErr bool 313 | }{ 314 | { 315 | name: "returns an error on an empty socket", 316 | args: args{ 317 | sock: socket.Socket{}, 318 | logger: &MockLogger{}, 319 | }, 320 | wantErr: true, 321 | }, 322 | { 323 | name: "returns an httpRunner when given an HTTPs socket", 324 | args: args{ 325 | sock: socket.Socket{ 326 | ID: "google_https", 327 | Name: "Google HTTPs", 328 | Host: "https://google.com", 329 | Port: 443, 330 | ExpectedHTTPCodes: []int{200}, 331 | PathHTTP: "/", 332 | }, 333 | logger: &MockLogger{}, 334 | }, 335 | want: &httpRunner{ 336 | client: &http.Client{}, 337 | logger: &MockLogger{}, 338 | }, 339 | wantErr: false, 340 | }, 341 | { 342 | name: "returns an httpRunner when given a HTTP socket", 343 | args: args{ 344 | sock: socket.Socket{ 345 | ID: "google_http", 346 | Name: "Google HTTP", 347 | Host: "http://www.google.com", 348 | Port: 80, 349 | ExpectedHTTPCodes: []int{200}, 350 | PathHTTP: "/", 351 | }, 352 | logger: &MockLogger{}, 353 | }, 354 | want: &httpRunner{ 355 | client: &http.Client{}, 356 | logger: &MockLogger{}, 357 | }, 358 | wantErr: false, 359 | }, 360 | { 361 | name: "returns a tcpRunner when given a TCP socket", 362 | args: args{ 363 | sock: socket.Socket{ 364 | Port: 80, 365 | ExpectedHTTPCodes: []int{200}, 366 | PathHTTP: "/", 367 | }, 368 | logger: &MockLogger{}, 369 | }, 370 | want: &tcpRunner{ 371 | logger: &MockLogger{}, 372 | }, 373 | wantErr: false, 374 | }, 375 | { 376 | name: "returns an icmpRunner when given an ICMP socket", 377 | args: args{ 378 | sock: socket.Socket{ 379 | Host: "google.com", 380 | }, 381 | logger: &MockLogger{}, 382 | }, 383 | want: &icmpRunner{ 384 | logger: &MockLogger{}, 385 | }, 386 | wantErr: false, 387 | }, 388 | } 389 | for _, tt := range tests { 390 | t.Run(tt.name, func(t *testing.T) { 391 | got, err := NewNetRunner(tt.args.sock, &MockLogger{}) 392 | if (err != nil) != tt.wantErr { 393 | t.Fatalf("NewNetRunner():\n error = %v\n wantErr = %v", err, tt.wantErr) 394 | } 395 | 396 | if tt.wantErr { 397 | return 398 | } 399 | 400 | if !reflect.DeepEqual(got, tt.want) { 401 | t.Fatalf("NewNetRunner():\n got = %v\n want = %v", got, tt.want) 402 | } 403 | }) 404 | } 405 | } 406 | 407 | // TestTcpRunner_RunTest is an integration test. It executes network calls to 408 | // external public servers. 409 | func TestTcpRunner_RunTest(t *testing.T) { 410 | type fields struct { 411 | verbose bool 412 | } 413 | type args struct { 414 | sock socket.Socket 415 | } 416 | tests := []struct { 417 | name string 418 | fields fields 419 | args args 420 | want socket.Result 421 | }{ 422 | { 423 | name: "returns a success on a call to a valid TCP server", 424 | fields: fields{ 425 | verbose: testing.Verbose(), 426 | }, 427 | args: args{ 428 | sock: socket.Socket{ 429 | ID: "google_tcp", 430 | Name: "Google TCP", 431 | Host: "google.com", 432 | Port: 80, 433 | }, 434 | }, 435 | want: socket.Result{ 436 | Socket: socket.Socket{ 437 | ID: "google_tcp", 438 | Name: "Google TCP", 439 | Host: "google.com", 440 | Port: 80, 441 | }, 442 | Passed: true, 443 | }, 444 | }, 445 | { 446 | name: "returns an error when the TCP connection fails", 447 | fields: fields{ 448 | verbose: testing.Verbose(), 449 | }, 450 | args: args{ 451 | sock: socket.Socket{ 452 | ID: "invalid_tcp", 453 | Name: "Invalid TCP endpoint", 454 | Host: "doesnotexist.invalid", 455 | Port: 80, 456 | }, 457 | }, 458 | want: socket.Result{ 459 | Socket: socket.Socket{ 460 | ID: "invalid_tcp", 461 | Name: "Invalid TCP endpoint", 462 | Host: "doesnotexist.invalid", 463 | Port: 80, 464 | }, 465 | Passed: false, 466 | Error: cmpopts.AnyError, 467 | }, 468 | }, 469 | } 470 | for _, tt := range tests { 471 | t.Run(tt.name, func(t *testing.T) { 472 | r := tcpRunner{ 473 | &MockLogger{}, 474 | } 475 | 476 | if got := r.RunTest(context.Background(), tt.args.sock); !cmp.Equal(got, tt.want, cmpopts.EquateErrors()) { 477 | t.Fatalf("tcpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 478 | } 479 | }) 480 | } 481 | } 482 | 483 | // TestHttpRunner_RunTest is an integration test. It executes network calls to 484 | // external public servers. 485 | func TestHttpRunner_RunTest(t *testing.T) { 486 | type args struct { 487 | sock socket.Socket 488 | } 489 | tests := []struct { 490 | name string 491 | runner httpRunner 492 | args args 493 | want socket.Result 494 | }{ 495 | { 496 | name: "returns a success on a call to a valid HTTPs server", 497 | runner: httpRunner{ 498 | client: &http.Client{}, 499 | logger: &MockLogger{}, 500 | }, 501 | args: args{ 502 | sock: socket.Socket{ 503 | ID: "google_http", 504 | Name: "Google HTTP", 505 | Host: "https://www.google.com", 506 | Port: 443, 507 | ExpectedHTTPCodes: []int{200}, 508 | PathHTTP: "/", 509 | }, 510 | }, 511 | want: socket.Result{ 512 | Socket: socket.Socket{ 513 | ID: "google_http", 514 | Name: "Google HTTP", 515 | Host: "https://www.google.com", 516 | Port: 443, 517 | ExpectedHTTPCodes: []int{200}, 518 | PathHTTP: "/", 519 | }, 520 | Passed: true, 521 | ResponseCode: 200, 522 | }, 523 | }, 524 | { 525 | name: "returns a failure on a call to an invalid HTTPs server", 526 | // Since both DNS and HTTPs use TCP, the conn opens successfully but, 527 | // the request timeouts while awaiting HTTP headers. 528 | runner: httpRunner{ 529 | client: &http.Client{Timeout: time.Second}, 530 | logger: &MockLogger{}, 531 | }, 532 | args: args{ 533 | sock: socket.Socket{ 534 | ID: "cloudflare_dns", 535 | Name: "Cloudflare DNS", 536 | Host: "https://1.1.1.1", 537 | Port: 53, 538 | ExpectedHTTPCodes: []int{200}, 539 | PathHTTP: "/", 540 | }, 541 | }, 542 | want: socket.Result{ 543 | Socket: socket.Socket{ 544 | ID: "cloudflare_dns", 545 | Name: "Cloudflare DNS", 546 | Host: "https://1.1.1.1", 547 | Port: 53, 548 | ExpectedHTTPCodes: []int{200}, 549 | PathHTTP: "/", 550 | }, 551 | Passed: false, 552 | Error: cmpopts.AnyError, 553 | }, 554 | }, 555 | } 556 | for _, tt := range tests { 557 | t.Run(tt.name, func(t *testing.T) { 558 | got := tt.runner.RunTest(context.Background(), tt.args.sock) 559 | if !cmp.Equal(got, tt.want, cmpopts.EquateErrors()) { 560 | t.Fatalf("httpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 561 | } 562 | }) 563 | } 564 | } 565 | 566 | // TestIcmpRunner_RunTest is an integration test. It executes network calls to 567 | // external public servers. 568 | // This test is common for all OS implementations except for Windows which is not supported. 569 | func TestIcmpRunner_RunTest(t *testing.T) { 570 | if runtime.GOOS == osWindows { 571 | t.Skip("ICMP tests are skipped on Windows") 572 | } 573 | 574 | type args struct { 575 | sock socket.Socket 576 | } 577 | 578 | tests := []struct { 579 | name string 580 | runner icmpRunner 581 | args args 582 | want socket.Result 583 | 584 | // A slice defining OSes on which the given test should be skipped 585 | skipOn []string 586 | }{ 587 | { 588 | name: "returns a success on a call to a valid host", 589 | runner: icmpRunner{ 590 | logger: &MockLogger{}, 591 | }, 592 | args: args{ 593 | sock: socket.Socket{ 594 | ID: "google_icmp", 595 | Name: "Google ICMP", 596 | Host: "google.com", 597 | }, 598 | }, 599 | want: socket.Result{ 600 | Socket: socket.Socket{ 601 | ID: "google_icmp", 602 | Name: "Google ICMP", 603 | Host: "google.com", 604 | }, 605 | Passed: true, 606 | }, 607 | skipOn: []string{"darwin"}, 608 | }, 609 | { 610 | name: "returns a success on a call to a valid IP address", 611 | runner: icmpRunner{ 612 | logger: &MockLogger{}, 613 | }, 614 | args: args{ 615 | sock: socket.Socket{ 616 | ID: "google_icmp", 617 | Name: "Google ICMP", 618 | Host: "8.8.8.8", 619 | }, 620 | }, 621 | want: socket.Result{ 622 | Socket: socket.Socket{ 623 | ID: "google_icmp", 624 | Name: "Google ICMP", 625 | Host: "8.8.8.8", 626 | }, 627 | Passed: true, 628 | }, 629 | skipOn: []string{"darwin"}, 630 | }, 631 | { 632 | name: "returns an error on an empty host", 633 | runner: icmpRunner{ 634 | logger: &MockLogger{}, 635 | }, 636 | args: args{ 637 | sock: socket.Socket{ 638 | ID: "empty_host", 639 | Name: "Empty Host", 640 | Host: "", 641 | }, 642 | }, 643 | want: socket.Result{ 644 | Socket: socket.Socket{ 645 | ID: "empty_host", 646 | Name: "Empty Host", 647 | Host: "", 648 | }, 649 | Passed: false, 650 | Error: cmpopts.AnyError, 651 | }, 652 | }, 653 | { 654 | name: "returns an error on an invalid IP address", 655 | runner: icmpRunner{ 656 | logger: &MockLogger{}, 657 | }, 658 | args: args{ 659 | sock: socket.Socket{ 660 | ID: "invalid_ip", 661 | Name: "Invalid IP", 662 | Host: "256.100.50.25", 663 | }, 664 | }, 665 | want: socket.Result{ 666 | Socket: socket.Socket{ 667 | ID: "invalid_ip", 668 | Name: "Invalid IP", 669 | Host: "256.100.50.25", 670 | }, 671 | Passed: false, 672 | Error: cmpopts.AnyError, 673 | }, 674 | }, 675 | // { 676 | // name: "returns a success on a call to localhost using hostname", 677 | // runner: icmpRunner{ 678 | // logger: &MockLogger{}, 679 | // }, 680 | // args: args{ 681 | // sock: socket.Socket{ 682 | // ID: "localhost_icmp", 683 | // Name: "Localhost ICMP", 684 | // Host: "localhost", 685 | // }, 686 | // }, 687 | // want: socket.Result{ 688 | // Socket: socket.Socket{ 689 | // ID: "localhost_icmp", 690 | // Name: "Localhost ICMP", 691 | // Host: "localhost", 692 | // }, 693 | // Passed: true, 694 | // }, 695 | // }, 696 | { 697 | name: "returns a success on a call to localhost using IP", 698 | runner: icmpRunner{ 699 | logger: &MockLogger{}, 700 | }, 701 | args: args{ 702 | sock: socket.Socket{ 703 | ID: "localhost_icmp", 704 | Name: "Localhost ICMP", 705 | Host: "127.0.0.1", 706 | }, 707 | }, 708 | want: socket.Result{ 709 | Socket: socket.Socket{ 710 | ID: "localhost_icmp", 711 | Name: "Localhost ICMP", 712 | Host: "127.0.0.1", 713 | }, 714 | Passed: true, 715 | }, 716 | }, 717 | } 718 | for _, tt := range tests { 719 | if tt.skipOn != nil && slices.Contains(tt.skipOn, runtime.GOOS) { 720 | t.Logf("skipping test %s on %s", tt.name, runtime.GOOS) 721 | continue 722 | } 723 | 724 | t.Run(tt.name, func(t *testing.T) { 725 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 726 | defer cancel() 727 | got := tt.runner.RunTest(ctx, tt.args.sock) 728 | if !cmp.Equal(got, tt.want, cmpopts.EquateErrors()) { 729 | t.Fatalf("icmpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 730 | } 731 | }) 732 | } 733 | } 734 | 735 | func TestIcmpRunner_RunTest_Windows(t *testing.T) { 736 | if runtime.GOOS != osWindows { 737 | t.Skip() 738 | } 739 | 740 | socket := socket.Socket{ 741 | ID: "google_icmp", 742 | Name: "Google ICMP", 743 | Host: "google.com", 744 | } 745 | 746 | runner, err := NewNetRunner(socket, &MockLogger{}) 747 | if err != nil { 748 | t.Error("failed to create a new Windows ICMP runner") 749 | } 750 | 751 | result := runner.RunTest(context.Background(), socket) 752 | if result.Error == nil { 753 | t.Error("expected error, got nil") 754 | } 755 | } 756 | --------------------------------------------------------------------------------