├── .dockerignore ├── .env.example ├── .github ├── dish_run.png ├── dish_telegram.png └── workflows │ ├── coverage.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── build └── Dockerfile ├── cmd └── dish │ ├── cli.go │ ├── main.go │ ├── runner.go │ └── runner_test.go ├── configs ├── demo_sockets.json └── prometheus-alert.yml ├── deployments └── docker-compose.yml ├── docker-compose.test.yml ├── go.mod ├── go.sum └── pkg ├── alert ├── alerter.go ├── api.go ├── api_test.go ├── formatter.go ├── formatter_test.go ├── notifier.go ├── pushgateway.go ├── pushgateway_test.go ├── telegram.go ├── telegram_test.go ├── transport.go ├── url.go ├── url_test.go ├── webhook.go └── webhook_test.go ├── config └── config.go ├── logger ├── console_logger.go ├── console_logger_test.go └── logger.go ├── netrunner ├── runner.go ├── runner_posix.go ├── runner_test.go └── runner_windows.go ├── socket ├── cache.go ├── cache_test.go ├── fetch_local.go ├── fetch_local_test.go ├── fetch_remote.go ├── fetch_remote_test.go ├── socket.go ├── socket_test.go ├── utils.go └── utils_test.go └── testhelpers ├── helpers.go └── http_client.go /.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 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # dish / environment constatns 2 | 3 | APP_NAME=dish 4 | APP_VERSION=1.11.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 | -------------------------------------------------------------------------------- /.github/dish_run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevxn/dish/c54b3c6b70539b6a91cb50ab1c9af474021fd2dd/.github/dish_run.png -------------------------------------------------------------------------------- /.github/dish_telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevxn/dish/c54b3c6b70539b6a91cb50ab1c9af474021fd2dd/.github/dish_telegram.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | linux_x64: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '1.24' 21 | - name: Build 22 | run: go build -v ./cmd/... 23 | - name: Test 24 | # Github Actions cloud runners block any incoming ICMP packets. 25 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 26 | 27 | windows_x64: 28 | runs-on: windows-2025 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: '1.24' 35 | - name: Build 36 | run: go build -v ./cmd/... 37 | - name: Test 38 | # Github Actions cloud runners block any incoming ICMP packets. 39 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 40 | 41 | linux_arm64: 42 | runs-on: ubuntu-24.04-arm 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Set up Go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version: '1.24' 49 | - name: Build 50 | run: go build -v ./cmd/... 51 | - name: Test 52 | # Github Actions cloud runners block any incoming ICMP packets. 53 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 54 | 55 | # windows_arm64: 56 | # runs-on: windows-11-arm 57 | # steps: 58 | # - uses: actions/checkout@v4 59 | # - name: Set up Go 60 | # uses: actions/setup-go@v4 61 | # with: 62 | # go-version: '1.24' 63 | # - name: Build 64 | # run: go build -v ./cmd/... 65 | # - name: Test 66 | # Github Actions cloud runners block any incoming ICMP packets. 67 | # run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 68 | 69 | macOS_intel: 70 | runs-on: macos-13 71 | steps: 72 | - uses: actions/checkout@v4 73 | - name: Set up Go 74 | uses: actions/setup-go@v5 75 | with: 76 | go-version: '1.24' 77 | - name: Build 78 | run: go build -v ./cmd/... 79 | - name: Test 80 | # Github Actions cloud runners block any incoming ICMP packets. 81 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 82 | 83 | macOS_arm64: 84 | runs-on: macos-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - name: Set up Go 88 | uses: actions/setup-go@v5 89 | with: 90 | go-version: '1.24' 91 | - name: Build 92 | run: go build -v ./cmd/... 93 | - name: Test 94 | # Github Actions cloud runners block any incoming ICMP packets. 95 | run: go test -v ./... -skip 'TestIcmpRunner_RunTest' 96 | 97 | # Self-hosted runner mainly intended for running ICMP integration tests which cannot be run in the GH runners due to incoming traffic being blocked 98 | self-hosted: 99 | runs-on: ${{ vars.TEST_RUNNER_LABEL }} 100 | steps: 101 | - uses: actions/checkout@v4 102 | - name: Run tests in a container 103 | env: 104 | PROJECT_NAME: dish 105 | run: make docker-test 106 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 | 119 | MAJOR := $(shell echo ${APP_VERSION} | cut -d. -f1) 120 | MINOR := $(shell echo ${APP_VERSION} | cut -d. -f2) 121 | PATCH := $(shell echo ${APP_VERSION} | cut -d. -f3) 122 | 123 | major: 124 | $(eval APP_VERSION := $(shell echo $$(( ${MAJOR} + 1 )).0.0)) 125 | $(call update_semver,${APP_VERSION}) 126 | 127 | minor: 128 | $(eval APP_VERSION := $(shell echo ${MAJOR}.$$(( ${MINOR} + 1 )).0)) 129 | $(call update_semver,${APP_VERSION}) 130 | 131 | patch: 132 | $(eval APP_VERSION := $(shell echo ${MAJOR}.${MINOR}.$$(( ${PATCH} + 1 )))) 133 | $(call update_semver,${APP_VERSION}) 134 | 135 | version: 136 | $(call print_info, Current version: ${APP_VERSION}...) 137 | 138 | binaries: 139 | @GOARCH=arm64 GOOS=linux go build -o dish-${LATEST_TAG}.linux-arm64 ${MAIN_PATH} 140 | @gzip dish-${LATEST_TAG}.linux-arm64 141 | @GOARCH=amd64 GOOS=linux go build -o dish-${LATEST_TAG}.linux-x86_64 ${MAIN_PATH} 142 | @gzip dish-${LATEST_TAG}.linux-x86_64 143 | @GOARCH=amd64 GOOS=windows go build -o dish-${LATEST_TAG}.windows-x86_64.exe ${MAIN_PATH} 144 | @gzip dish-${LATEST_TAG}.windows-x86_64.exe 145 | @GOARCH=arm64 GOOS=darwin go build -o dish-${LATEST_TAG}.macos-arm64 ${MAIN_PATH} 146 | @gzip dish-${LATEST_TAG}.macos-arm64 147 | @GOARCH=amd64 GOOS=darwin go build -o dish-${LATEST_TAG}.macos-x86_64 ${MAIN_PATH} 148 | @gzip dish-${LATEST_TAG}.macos-x86_64 149 | -------------------------------------------------------------------------------- /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 | [![libs.tech recommends](https://libs.tech/project/468033120/badge.svg)](https://libs.tech/project/468033120/dish) 10 | 11 | + __Tiny__ one-shot monitoring service 12 | + __Remote__ configuration of independent 'dish network' (via loading the socket list to be checked from a remote API) 13 | + __Fast__ concurrent testing, low overall execution time, 10-sec timeout per socket by default 14 | + __Zero__ dependencies 15 | 16 | ## Use Cases 17 | 18 | + Lightweight health checks of HTTP and ICMP endpoints and TCP sockets 19 | + Decentralized monitoring with standalone dish instances deployed on different hosts that pull configuration from a common API 20 | + Cron-driven one-shot checks without the need for any long-running agents 21 | 22 | ## Install 23 | 24 | ### Using go install 25 | 26 | ```shell 27 | go install go.vxn.dev/dish/cmd/dish@latest 28 | ``` 29 | 30 | ### Using Homebrew 31 | 32 | ```shell 33 | brew install dish 34 | ``` 35 | 36 | ### Manual Download 37 | 38 | Download the binary built for your OS and architecture from the [Releases](https://github.com/thevxn/dish/releases) section. 39 | 40 | ## Usage 41 | 42 | ``` 43 | dish [FLAGS] SOURCE 44 | ``` 45 | 46 | ![dish run](.github/dish_run.png) 47 | 48 | ### Source 49 | 50 | The list of endpoints to be checked can be provided in 2 ways: 51 | 52 | 1. A local JSON file 53 | + E.g., the `./configs/demo_sockets.json` file included in this repository as an example. 54 | 2. A remote RESTful JSON API endpoint 55 | + The list of sockets retrieved from this endpoint can also be locally cached (see the `-cache`, `-cacheDir` and `-cacheTTL` flags below). 56 | + 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. 57 | 58 | For the expected JSON schema of the list of sockets to be checked, see `./configs/demo_sockets.json`. 59 | 60 | ```bash 61 | # local JSON file 62 | dish /opt/dish/sockets.json 63 | 64 | # remote JSON API source 65 | dish http://restapi.example.com/dish/sockets/:instance 66 | ``` 67 | 68 | ### Specifying Protocol 69 | 70 | 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: 71 | 72 | + If the `host_name` field starts with "http://" or "https://", __HTTP__ will be used. 73 | + If the `port_tcp` field is between 1 and 65535, __TCP__ will be used. 74 | + If `host_name` is not empty, __ICMP__ will be used. 75 | + If none of the above conditions are met, the check fails. 76 | 77 | __Note:__ ICMP is currently not supported on Windows. 78 | 79 | ### Flags 80 | 81 | ``` 82 | dish -h 83 | Usage of dish: 84 | -cache 85 | a bool, specifies whether to cache the socket list fetched from the remote API source 86 | -cacheDir string 87 | a string, specifies the directory used to cache the socket list fetched from the remote API source (default ".cache") 88 | -cacheTTL uint 89 | an int, time duration (in minutes) for which the cached list of sockets is valid (default 10) 90 | -hname string 91 | 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) 92 | -hvalue string 93 | 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) 94 | -machineNotifySuccess 95 | a bool, specifies whether successful checks with no failures should be reported to machine channels 96 | -name string 97 | a string, dish instance name (default "generic-dish") 98 | -target string 99 | a string, result update path/URL to pushgateway, plaintext/byte output 100 | -telegramBotToken string 101 | a string, Telegram bot private token 102 | -telegramChatID string 103 | a string, Telegram chat/channel ID 104 | -textNotifySuccess 105 | a bool, specifies whether successful checks with no failures should be reported to text channels 106 | -timeout uint 107 | an int, timeout in seconds for http and tcp calls (default 10) 108 | -updateURL string 109 | a string, API endpoint URL for pushing results 110 | -verbose 111 | a bool, console stdout logging toggle 112 | -webhookURL string 113 | a string, URL of webhook endpoint 114 | ``` 115 | 116 | ### Alerting 117 | 118 | When a socket test fails, it's always good to be notified. For this purpose, dish provides 4 different ways of doing so (can be combined): 119 | 120 | + Test results upload to a remote JSON API (using the `-updateURL` flag) 121 | + Check results as the Telegram message body (via the `-telegramBotToken` and `-telegramChatID` flags) 122 | + Failed count and last test timestamp update to Pushgateway for Prometheus (using the `-target` flag) 123 | + Test results push to a webhook URL (using the `-webhookURL` flag) 124 | 125 | Whether successful runs with no failed checks should be reported can also be configured using flags: 126 | 127 | + `-textNotifySuccess` for text channels (e.g. Telegram) 128 | + `-machineNotifySuccess` for machine channels (e.g. webhooks, remote API or Pushgateway) 129 | 130 | ![telegram-alerting](/.github/dish_telegram.png) 131 | 132 | (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.) 133 | 134 | ### Examples 135 | 136 | One way to run dish is to build and install a binary executable. 137 | 138 | ```shell 139 | # Fetch and install the specific version 140 | go install go.vxn.dev/dish/cmd/dish@latest 141 | 142 | export PATH=$PATH:~/go/bin 143 | 144 | # Load sockets from sockets.json file, and use Telegram 145 | # provider for alerting 146 | dish -telegramChatID "-123456789" \ 147 | -telegramBotToken "123:AAAbcD_ef" \ 148 | sockets.json 149 | 150 | # Use remote JSON API service as socket source, and push 151 | # the results to Pushgateway 152 | dish -target https://pushgw.example.com/ \ 153 | https://api.example.com/dish/sockets 154 | ``` 155 | 156 | #### Using Docker 157 | 158 | ```shell 159 | # Copy, and/or edit dot-env file (optional) 160 | cp .env.example .env 161 | vi .env 162 | 163 | # Build a Docker image 164 | make build 165 | 166 | # Run using docker compose stack 167 | make run 168 | 169 | # Run using native docker run 170 | docker run --rm \ 171 | dish:1.11.0-go1.24 \ 172 | -verbose \ 173 | -target https://pushgateway.example.com \ 174 | https://api.example.com 175 | ``` 176 | 177 | #### Bash script and Cronjob 178 | 179 | Create a bash script to easily deploy dish and update its settings: 180 | 181 | ```shell 182 | vi tiny-dish-run.sh 183 | ``` 184 | 185 | ```shell 186 | #!/bin/bash 187 | 188 | TELEGRAM_TOKEN="123:AAAbcD_ef" 189 | TELEGRAM_CHATID="-123456789" 190 | 191 | SOURCE_URL=https://api.example.com/dish/sockets 192 | UPDATE_URL=https://api.example.com/dish/sockets/results 193 | TARGET_URL=https://pushgw.example.com 194 | 195 | DISH_TAG=dish:1.11.0-go1.24 196 | INSTANCE_NAME=tiny-dish 197 | 198 | API_TOKEN=AbCd 199 | 200 | docker run --rm \ 201 | ${DISH_TAG} \ 202 | -name ${INSTANCE_NAME} \ 203 | -hvalue ${API_TOKEN} \ 204 | -hname X-Auth-Token \ 205 | -target ${TARGET_URL} \ 206 | -updateURL ${UPDATE_URL} \ 207 | -telegramBotToken ${TELEGRAM_TOKEN} \ 208 | -telegramChatID ${TELEGRAM_CHATID} \ 209 | -timeout 15 \ 210 | -verbose \ 211 | ${SOURCE_URL} 212 | ``` 213 | 214 | Make it an executable: 215 | 216 | ```shell 217 | chmod +x tiny-dish-run.sh 218 | ``` 219 | 220 | ##### Cronjob to run periodically 221 | 222 | ```shell 223 | crontab -e 224 | ``` 225 | 226 | ```shell 227 | # m h dom mon dow command 228 | MAILTO=monitoring@example.com 229 | 230 | */2 * * * * /home/user/tiny-dish-run.sh 231 | ``` 232 | 233 | ### Integration Example 234 | 235 | 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). 236 | 237 | ## Articles 238 | 239 | + [dish deep-dive article](https://blog.vxn.dev/dish-monitoring-service) 240 | + [dish history article](https://krusty.space/projects/dish/) 241 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # dish / Dockerfile 3 | # 4 | 5 | # 6 | # stage 0 --- build 7 | # 8 | 9 | # https://hub.docker.com/_/golang 10 | 11 | ARG ALPINE_VERSION 3.20 12 | ARG GOLANG_VERSION 1.23 13 | FROM golang:${GOLANG_VERSION}-alpine${ALPINE_VERSION} AS dish-build 14 | 15 | LABEL org.opencontainers.image.authors="krusty@vxn.dev, tack@vxn.dev, krixlion@vxn.dev" 16 | 17 | ARG APP_NAME dish 18 | 19 | WORKDIR /go/src/${APP_NAME} 20 | COPY . /go/src/${APP_NAME} 21 | 22 | # build and install the binary 23 | RUN go install ./cmd/dish/ 24 | 25 | # 26 | # stage 1 --- release 27 | # 28 | 29 | FROM alpine:${ALPINE_VERSION} AS dish-release 30 | 31 | WORKDIR /opt 32 | 33 | COPY configs/demo_sockets.json /opt/ 34 | COPY --from=dish-build /go/bin/dish /opt/dish 35 | RUN ln -s /opt/dish /usr/local/bin 36 | 37 | ENTRYPOINT [ "dish" ] 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cmd/dish/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "go.vxn.dev/dish/pkg/alert" 10 | "go.vxn.dev/dish/pkg/config" 11 | ) 12 | 13 | func main() { 14 | cfg, err := config.NewConfig(flag.CommandLine, os.Args[1:]) 15 | if err != nil { 16 | // If the error is caused due to no source being provided, print help 17 | if errors.Is(err, config.ErrNoSourceProvided) { 18 | printHelp() 19 | os.Exit(1) 20 | } 21 | // Otherwise, print the error 22 | log.Print("error loading config: ", err) 23 | return 24 | } 25 | 26 | log.Println("dish run: started") 27 | 28 | // Run tests on sockets 29 | res, err := runTests(cfg) 30 | if err != nil { 31 | log.Println(err) 32 | return 33 | } 34 | 35 | // Submit results and alerts 36 | alert.HandleAlerts(res.messengerText, res.results, res.failedCount, cfg) 37 | 38 | if res.failedCount > 0 { 39 | log.Println("dish run: some tests failed:\n", res.messengerText) 40 | return 41 | } 42 | 43 | log.Println("dish run: all tests ok") 44 | } 45 | -------------------------------------------------------------------------------- /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/netrunner" 10 | "go.vxn.dev/dish/pkg/socket" 11 | ) 12 | 13 | // testResults holds the overall results of all socket checks combined. 14 | type testResults struct { 15 | messengerText string 16 | results *alert.Results 17 | failedCount int 18 | } 19 | 20 | // fanInChannels collects results from multiple goroutines. 21 | func fanInChannels(channels ...chan socket.Result) <-chan socket.Result { 22 | var wg sync.WaitGroup 23 | out := make(chan socket.Result) 24 | 25 | // Start a goroutine for each channel 26 | for _, channel := range channels { 27 | wg.Add(1) 28 | go func(ch <-chan socket.Result) { 29 | defer wg.Done() 30 | for result := range ch { 31 | // Forward the result to the output channel 32 | out <- result 33 | } 34 | }(channel) 35 | } 36 | 37 | // Close the output channel once all workers are done 38 | go func() { 39 | wg.Wait() 40 | close(out) 41 | }() 42 | 43 | return out 44 | } 45 | 46 | // runTests orchestrates the process of checking of a list of sockets. It fetches the socket list, runs socket checks, collects results and returns them. 47 | func runTests(cfg *config.Config) (*testResults, error) { 48 | // Load socket list to run tests on 49 | list, err := socket.FetchSocketList(cfg) 50 | if err != nil { 51 | return nil, fmt.Errorf("error loading socket list: %w", err) 52 | } 53 | 54 | // Print loaded sockets if flag is set in cfg 55 | if cfg.Verbose { 56 | socket.PrintSockets(list) 57 | } 58 | 59 | testResults := &testResults{ 60 | messengerText: "", 61 | results: &alert.Results{Map: make(map[string]bool)}, 62 | failedCount: 0, 63 | } 64 | 65 | var ( 66 | // 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. 67 | channels = make([](chan socket.Result), len(list.Sockets)) 68 | 69 | wg sync.WaitGroup 70 | i int 71 | ) 72 | 73 | // Start goroutines for each socket test 74 | for _, sock := range list.Sockets { 75 | wg.Add(1) 76 | channels[i] = make(chan socket.Result) 77 | 78 | go netrunner.RunSocketTest(sock, channels[i], &wg, cfg) 79 | i++ 80 | } 81 | 82 | // Merge channels into one 83 | results := fanInChannels(channels...) 84 | wg.Wait() 85 | 86 | // Collect results 87 | for result := range results { 88 | if !result.Passed || result.Error != nil { 89 | testResults.failedCount++ 90 | } 91 | if !result.Passed || cfg.TextNotifySuccess { 92 | testResults.messengerText += alert.FormatMessengerText(result) 93 | } 94 | testResults.results.Map[result.Socket.ID] = result.Passed 95 | } 96 | 97 | return testResults, nil 98 | } 99 | -------------------------------------------------------------------------------- /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 | 32 | testChannels := []chan socket.Result{} 33 | 34 | for range 3 { 35 | c := make(chan socket.Result) 36 | testChannels = append(testChannels, c) 37 | } 38 | 39 | go func() { 40 | for i, channel := range testChannels { 41 | channel <- socket.Result{ 42 | Socket: socket.Socket{}, 43 | Passed: true, 44 | ResponseCode: 200 + i, 45 | } 46 | close(channel) 47 | } 48 | }() 49 | 50 | resultingChan := fanInChannels(testChannels...) 51 | actual := []socket.Result{} 52 | for result := range resultingChan { 53 | actual = append(actual, result) 54 | } 55 | 56 | expected := []socket.Result{ 57 | { 58 | Socket: socket.Socket{}, 59 | Passed: true, 60 | ResponseCode: 200, 61 | }, { 62 | Socket: socket.Socket{}, 63 | Passed: true, 64 | ResponseCode: 201, 65 | }, { 66 | Socket: socket.Socket{}, 67 | Passed: true, 68 | ResponseCode: 202, 69 | }, 70 | } 71 | 72 | if !compareResults(expected, actual) { 73 | t.Fatalf("expected: %+v, got: %+v", expected, actual) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 sih socket down alert 14 | - alert: DishSocketDown 15 | expr: dish_failed_count > 0 16 | for: 3m 17 | 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 ./... -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.vxn.dev/dish 2 | 3 | go 1.22 4 | 5 | require github.com/google/go-cmp v0.7.0 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "log" 7 | "net/http" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | ) 11 | 12 | func HandleAlerts(messengerText string, results *Results, failedCount int, config *config.Config) { 13 | notifier := NewNotifier(http.DefaultClient, config) 14 | if err := notifier.SendChatNotifications(messengerText, failedCount); err != nil { 15 | log.Printf("some error(s) encountered when sending chat notifications: \n%v", err) 16 | } 17 | if err := notifier.SendMachineNotifications(results, failedCount); err != nil { 18 | log.Printf("some error(s) encountered when sending machine notifications: \n%v", err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/alert/api.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "go.vxn.dev/dish/pkg/config" 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 | } 21 | 22 | func NewAPISender(httpClient HTTPClient, config *config.Config) (*apiSender, error) { 23 | parsedURL, err := parseAndValidateURL(config.ApiURL, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return &apiSender{ 29 | httpClient: httpClient, 30 | url: parsedURL.String(), 31 | headerName: config.ApiHeaderName, 32 | headerValue: config.ApiHeaderValue, 33 | verbose: config.Verbose, 34 | notifySuccess: config.MachineNotifySuccess, 35 | }, nil 36 | } 37 | 38 | func (s *apiSender) send(m *Results, failedCount int) error { 39 | // If no checks failed and success should not be notified, there is nothing to send 40 | if failedCount == 0 && !s.notifySuccess { 41 | if s.verbose { 42 | log.Println("no sockets failed, nothing will be sent to remote API") 43 | } 44 | return nil 45 | } 46 | 47 | jsonData, err := json.Marshal(m) 48 | if err != nil { 49 | return fmt.Errorf("failed to marshal JSON: %w", err) 50 | } 51 | 52 | bodyReader := bytes.NewReader(jsonData) 53 | 54 | if s.verbose { 55 | log.Printf("prepared remote API data: %s", string(jsonData)) 56 | } 57 | 58 | // If custom header & value is provided (mostly used for auth purposes), include it in the request 59 | opts := []func(*submitOptions){} 60 | if s.headerName != "" && s.headerValue != "" { 61 | opts = append(opts, withHeader(s.headerName, s.headerValue)) 62 | } 63 | 64 | err = handleSubmit(s.httpClient, http.MethodPost, s.url, bodyReader, opts...) 65 | if err != nil { 66 | return fmt.Errorf("error pushing results to remote API: %w", err) 67 | } 68 | 69 | log.Println("results pushed to remote API") 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/alert/api_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | "go.vxn.dev/dish/pkg/testhelpers" 9 | ) 10 | 11 | func TestNewAPISender(t *testing.T) { 12 | mockHTTPClient := &testhelpers.SuccessStatusHTTPClient{} 13 | 14 | url := "https://abc123.xyz.com" 15 | headerName := "X-Api-Key" 16 | headerValue := "abc123" 17 | notifySuccess := false 18 | verbose := false 19 | 20 | expected := &apiSender{ 21 | httpClient: mockHTTPClient, 22 | url: url, 23 | headerName: headerName, 24 | headerValue: headerValue, 25 | notifySuccess: notifySuccess, 26 | verbose: verbose, 27 | } 28 | 29 | cfg := &config.Config{ 30 | ApiURL: url, 31 | ApiHeaderName: headerName, 32 | ApiHeaderValue: headerValue, 33 | MachineNotifySuccess: notifySuccess, 34 | Verbose: verbose, 35 | } 36 | 37 | actual, _ := NewAPISender(mockHTTPClient, cfg) 38 | 39 | if !reflect.DeepEqual(expected, actual) { 40 | t.Fatalf("expected %v, got %v", expected, actual) 41 | } 42 | } 43 | 44 | func TestSend_API(t *testing.T) { 45 | url := "https://abc123.xyz.com" 46 | headerName := "X-Api-Key" 47 | headerValue := "abc123" 48 | 49 | successResults := Results{ 50 | Map: map[string]bool{ 51 | "test": true, 52 | }, 53 | } 54 | failedResults := Results{ 55 | Map: map[string]bool{ 56 | "test": false, 57 | }, 58 | } 59 | mixedResults := Results{ 60 | Map: map[string]bool{ 61 | "test1": true, 62 | "test2": false, 63 | }, 64 | } 65 | 66 | newConfig := func(headerName, headerValue string, notifySuccess, verbose bool) *config.Config { 67 | return &config.Config{ 68 | ApiURL: url, 69 | MachineNotifySuccess: notifySuccess, 70 | Verbose: verbose, 71 | ApiHeaderName: headerName, 72 | ApiHeaderValue: headerValue, 73 | } 74 | } 75 | 76 | tests := []struct { 77 | name string 78 | client HTTPClient 79 | results Results 80 | failedCount int 81 | notifySuccess bool 82 | headerName string 83 | headerValue string 84 | verbose bool 85 | wantErr bool 86 | }{ 87 | { 88 | name: "Failed Sockets", 89 | client: &testhelpers.SuccessStatusHTTPClient{}, 90 | results: failedResults, 91 | failedCount: 1, 92 | notifySuccess: false, 93 | headerName: headerName, 94 | headerValue: headerValue, 95 | verbose: false, 96 | wantErr: false, 97 | }, 98 | { 99 | name: "Failed Sockets - Verbose", 100 | client: &testhelpers.SuccessStatusHTTPClient{}, 101 | results: failedResults, 102 | failedCount: 1, 103 | notifySuccess: false, 104 | headerName: headerName, 105 | headerValue: headerValue, 106 | verbose: true, 107 | wantErr: false, 108 | }, 109 | { 110 | name: "No Failed Sockets With notifySuccess", 111 | client: &testhelpers.SuccessStatusHTTPClient{}, 112 | results: successResults, 113 | failedCount: 0, 114 | notifySuccess: true, 115 | headerName: headerName, 116 | headerValue: headerValue, 117 | verbose: false, 118 | wantErr: false, 119 | }, 120 | { 121 | name: "No Failed Sockets Without notifySuccess", 122 | client: &testhelpers.SuccessStatusHTTPClient{}, 123 | results: successResults, 124 | failedCount: 0, 125 | notifySuccess: false, 126 | headerName: headerName, 127 | headerValue: headerValue, 128 | verbose: false, 129 | wantErr: false, 130 | }, 131 | { 132 | name: "No Failed Sockets Without notifySuccess - Verbose", 133 | client: &testhelpers.SuccessStatusHTTPClient{}, 134 | results: successResults, 135 | failedCount: 0, 136 | notifySuccess: false, 137 | headerName: headerName, 138 | headerValue: headerValue, 139 | verbose: true, 140 | wantErr: false, 141 | }, 142 | { 143 | name: "Mixed Results With notifySuccess", 144 | client: &testhelpers.SuccessStatusHTTPClient{}, 145 | results: mixedResults, 146 | failedCount: 1, 147 | notifySuccess: true, 148 | headerName: "", 149 | headerValue: "", 150 | verbose: false, 151 | wantErr: false, 152 | }, 153 | { 154 | name: "Mixed Results Without notifySuccess", 155 | client: &testhelpers.SuccessStatusHTTPClient{}, 156 | results: mixedResults, 157 | failedCount: 1, 158 | notifySuccess: false, 159 | headerName: "", 160 | headerValue: "", 161 | verbose: false, 162 | wantErr: false, 163 | }, 164 | { 165 | name: "No Custom Header", 166 | client: &testhelpers.SuccessStatusHTTPClient{}, 167 | results: failedResults, 168 | failedCount: 1, 169 | notifySuccess: false, 170 | headerName: "", 171 | headerValue: "", 172 | verbose: false, 173 | wantErr: false, 174 | }, 175 | { 176 | name: "Network Error When Pushing to Remote API", 177 | client: &testhelpers.FailureHTTPClient{}, 178 | results: failedResults, 179 | failedCount: 1, 180 | notifySuccess: false, 181 | headerName: headerName, 182 | headerValue: headerValue, 183 | verbose: false, 184 | wantErr: true, 185 | }, 186 | { 187 | name: "Unexpected Response Code From Remote API", 188 | client: &testhelpers.ErrorStatusHTTPClient{}, 189 | results: failedResults, 190 | failedCount: 1, 191 | notifySuccess: false, 192 | headerName: headerName, 193 | headerValue: headerValue, 194 | verbose: false, 195 | wantErr: true, 196 | }, 197 | { 198 | name: "Error Reading Response Body From Remote API", 199 | client: &testhelpers.InvalidResponseBodyHTTPClient{}, 200 | results: failedResults, 201 | failedCount: 1, 202 | notifySuccess: false, 203 | headerName: headerName, 204 | headerValue: headerValue, 205 | verbose: true, 206 | wantErr: true, 207 | }, 208 | } 209 | 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | cfg := newConfig(tt.headerName, tt.headerValue, tt.notifySuccess, tt.verbose) 213 | sender, err := NewAPISender(tt.client, cfg) 214 | if err != nil { 215 | t.Fatalf("failed to create API sender instance: %v", err) 216 | } 217 | 218 | err = sender.send(&tt.results, tt.failedCount) 219 | if tt.wantErr != (err != nil) { 220 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 221 | } 222 | }) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /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 | 11 | status := "failed" 12 | if result.Passed { 13 | status = "success" 14 | } 15 | 16 | text := fmt.Sprintf("• %s:%d", result.Socket.Host, result.Socket.Port) 17 | 18 | if result.Socket.PathHTTP != "" { 19 | text += result.Socket.PathHTTP 20 | } 21 | 22 | text += " -- " + status 23 | 24 | if status == "failed" { 25 | text += " \u274C" // ❌ 26 | text += " -- " 27 | text += result.Error.Error() 28 | } else { 29 | text += " \u2705" // ✅ 30 | } 31 | 32 | text += "\n" 33 | 34 | return text 35 | } 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/alert/notifier.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "log" 7 | "net/http" 8 | 9 | "go.vxn.dev/dish/pkg/config" 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 | type MachineNotifier interface { 20 | send(*Results, int) error 21 | } 22 | 23 | type notifier struct { 24 | verbose bool 25 | chatNotifiers []ChatNotifier 26 | machineNotifiers []MachineNotifier 27 | } 28 | 29 | type HTTPClient interface { 30 | Do(req *http.Request) (*http.Response, error) 31 | Get(url string) (*http.Response, error) 32 | Post(url string, contentType string, body io.Reader) (*http.Response, error) 33 | } 34 | 35 | // 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. 36 | func NewNotifier(httpClient HTTPClient, config *config.Config) *notifier { 37 | // Set chat integrations to be notified (e.g. Telegram) 38 | notificationSenders := make([]ChatNotifier, 0) 39 | 40 | // Telegram 41 | if config.TelegramBotToken != "" && config.TelegramChatID != "" { 42 | notificationSenders = append(notificationSenders, NewTelegramSender(httpClient, config)) 43 | } 44 | 45 | // Set machine interface integrations to be notified (e.g. Webhooks) 46 | payloadSenders := make([]MachineNotifier, 0) 47 | 48 | // Remote API 49 | if config.ApiURL != "" { 50 | apiSender, err := NewAPISender(httpClient, config) 51 | if err != nil { 52 | log.Println("error creating new remote API sender:", err) 53 | } else { 54 | payloadSenders = append(payloadSenders, apiSender) 55 | } 56 | } 57 | 58 | // Webhooks 59 | if config.WebhookURL != "" { 60 | webhookSender, err := NewWebhookSender(httpClient, config) 61 | if err != nil { 62 | log.Println("error creating new webhook sender:", err) 63 | } else { 64 | payloadSenders = append(payloadSenders, webhookSender) 65 | } 66 | } 67 | 68 | // Pushgateway 69 | if config.PushgatewayURL != "" { 70 | pgwSender, err := NewPushgatewaySender(httpClient, config) 71 | if err != nil { 72 | log.Println("error creating new Pushgateway sender:", err) 73 | } else { 74 | payloadSenders = append(payloadSenders, pgwSender) 75 | } 76 | } 77 | 78 | return ¬ifier{ 79 | verbose: config.Verbose, 80 | chatNotifiers: notificationSenders, 81 | machineNotifiers: payloadSenders, 82 | } 83 | } 84 | 85 | func (n *notifier) SendChatNotifications(m string, failedCount int) error { 86 | var errs []error 87 | 88 | if len(n.chatNotifiers) == 0 { 89 | log.Println("no chat notification receivers configured, no notifications will be sent") 90 | 91 | return nil 92 | } 93 | 94 | for _, sender := range n.chatNotifiers { 95 | if err := sender.send(m, failedCount); err != nil { 96 | log.Printf("failed to send notification using %T: %v", sender, err) 97 | errs = append(errs, err) 98 | } 99 | } 100 | 101 | if len(errs) > 0 { 102 | return errors.Join(errs...) 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (n *notifier) SendMachineNotifications(m *Results, failedCount int) error { 109 | var errs []error 110 | 111 | if len(n.machineNotifiers) == 0 { 112 | log.Println("no machine interface payload receivers configured, no notifications will be sent") 113 | 114 | return nil 115 | } 116 | for _, sender := range n.machineNotifiers { 117 | if err := sender.send(m, failedCount); err != nil { 118 | log.Printf("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 | -------------------------------------------------------------------------------- /pkg/alert/pushgateway.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "text/template" 9 | 10 | "go.vxn.dev/dish/pkg/config" 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 | } 39 | 40 | // 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. 41 | func NewPushgatewaySender(httpClient HTTPClient, config *config.Config) (*pushgatewaySender, error) { 42 | // Parse and validate the provided URL 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 | }, nil 62 | } 63 | 64 | // createMessage returns a string containing the message text in Pushgateway-specific format. 65 | func (s *pushgatewaySender) createMessage(failedCount int) (string, error) { 66 | var buf bytes.Buffer 67 | err := s.tmpl.Execute(&buf, messageData{FailedCount: failedCount}) 68 | if err != nil { 69 | return "", fmt.Errorf("error executing Pushgateway message template: %w", err) 70 | } 71 | 72 | return buf.String(), nil 73 | } 74 | 75 | // Send pushes the results to Pushgateway. 76 | // 77 | // 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. 78 | func (s *pushgatewaySender) send(_ *Results, failedCount int) error { 79 | // If no checks failed and success should not be notified, there is nothing to send 80 | if failedCount == 0 && !s.notifySuccess { 81 | if s.verbose { 82 | log.Println("no sockets failed, nothing will be sent to Pushgateway") 83 | } 84 | return nil 85 | } 86 | 87 | msg, err := s.createMessage(failedCount) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | bodyReader := bytes.NewReader([]byte(msg)) 93 | 94 | formattedURL := s.url + "/metrics/job/" + jobName + "/instance/" + s.instanceName 95 | 96 | err = handleSubmit(s.httpClient, http.MethodPut, formattedURL, bodyReader, withContentType("application/byte")) 97 | if err != nil { 98 | return fmt.Errorf("error pushing results to Pushgateway: %w", err) 99 | } 100 | 101 | log.Println("results pushed to Pushgateway") 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/alert/pushgateway_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | "go.vxn.dev/dish/pkg/testhelpers" 9 | ) 10 | 11 | func TestNewPushgatewaySender(t *testing.T) { 12 | mockHTTPClient := &testhelpers.SuccessStatusHTTPClient{} 13 | 14 | url := "https://abc123.xyz.com" 15 | instanceName := "test-instance" 16 | verbose := false 17 | notifySuccess := false 18 | 19 | expected := &pushgatewaySender{ 20 | httpClient: mockHTTPClient, 21 | url: url, 22 | instanceName: "test-instance", 23 | notifySuccess: notifySuccess, 24 | verbose: verbose, 25 | // template will be compared based on its output, no need for it here 26 | } 27 | 28 | cfg := &config.Config{ 29 | PushgatewayURL: url, 30 | InstanceName: instanceName, 31 | Verbose: verbose, 32 | MachineNotifySuccess: notifySuccess, 33 | } 34 | 35 | actual, err := NewPushgatewaySender(mockHTTPClient, cfg) 36 | if err != nil { 37 | t.Fatalf("error creating a new Pushgateway sender instance: %v", err) 38 | } 39 | 40 | // Compare fields individually due to complex structs 41 | if expected.url != actual.url { 42 | t.Errorf("expected url: %s, got: %s", expected.url, actual.url) 43 | } 44 | if expected.instanceName != actual.instanceName { 45 | t.Errorf("expected instanceName: %s, got: %s", expected.instanceName, actual.instanceName) 46 | } 47 | if expected.verbose != actual.verbose { 48 | t.Errorf("expected verbose: %v, got: %v", expected.verbose, actual.verbose) 49 | } 50 | if expected.notifySuccess != actual.notifySuccess { 51 | t.Errorf("expected notifySuccess: %v, got: %v", expected.notifySuccess, actual.notifySuccess) 52 | } 53 | if fmt.Sprintf("%T", expected.httpClient) != fmt.Sprintf("%T", actual.httpClient) { 54 | t.Errorf("expected httpClient type: %T, got: %T", expected.httpClient, actual.httpClient) 55 | } 56 | } 57 | 58 | func TestSend_Pushgateway(t *testing.T) { 59 | url := "https://abc123.xyz.com" 60 | instanceName := "test-instance" 61 | 62 | successResults := Results{ 63 | Map: map[string]bool{ 64 | "test": true, 65 | }, 66 | } 67 | failedResults := Results{ 68 | Map: map[string]bool{ 69 | "test": false, 70 | }, 71 | } 72 | mixedResults := Results{ 73 | Map: map[string]bool{ 74 | "test1": true, 75 | "test2": false, 76 | }, 77 | } 78 | 79 | newConfig := func(url, instanceName string, notifySuccess, verbose bool) *config.Config { 80 | return &config.Config{ 81 | PushgatewayURL: url, 82 | InstanceName: instanceName, 83 | Verbose: verbose, 84 | MachineNotifySuccess: notifySuccess, 85 | } 86 | } 87 | 88 | tests := []struct { 89 | name string 90 | client HTTPClient 91 | results Results 92 | failedCount int 93 | instanceName string 94 | notifySuccess bool 95 | verbose bool 96 | wantErr bool 97 | }{ 98 | { 99 | name: "Failed Sockets", 100 | client: &testhelpers.SuccessStatusHTTPClient{}, 101 | results: failedResults, 102 | failedCount: 1, 103 | instanceName: instanceName, 104 | notifySuccess: false, 105 | verbose: false, 106 | wantErr: false, 107 | }, 108 | { 109 | name: "Failed Sockets - Verbose", 110 | client: &testhelpers.SuccessStatusHTTPClient{}, 111 | results: failedResults, 112 | failedCount: 1, 113 | instanceName: instanceName, 114 | notifySuccess: false, 115 | verbose: true, 116 | wantErr: false, 117 | }, 118 | { 119 | name: "No Failed Sockets With notifySuccess", 120 | client: &testhelpers.SuccessStatusHTTPClient{}, 121 | results: successResults, 122 | failedCount: 0, 123 | instanceName: instanceName, 124 | notifySuccess: true, 125 | verbose: false, 126 | wantErr: false, 127 | }, 128 | { 129 | name: "No Failed Sockets Without notifySuccess", 130 | client: &testhelpers.SuccessStatusHTTPClient{}, 131 | results: successResults, 132 | failedCount: 0, 133 | instanceName: instanceName, 134 | notifySuccess: false, 135 | verbose: false, 136 | wantErr: false, 137 | }, 138 | { 139 | name: "No Failed Sockets Without notifySuccess - Verbose", 140 | client: &testhelpers.SuccessStatusHTTPClient{}, 141 | results: successResults, 142 | failedCount: 0, 143 | instanceName: instanceName, 144 | notifySuccess: false, 145 | verbose: true, 146 | wantErr: false, 147 | }, 148 | { 149 | name: "Mixed Results With notifySuccess", 150 | client: &testhelpers.SuccessStatusHTTPClient{}, 151 | results: mixedResults, 152 | failedCount: 1, 153 | instanceName: instanceName, 154 | notifySuccess: true, 155 | verbose: false, 156 | wantErr: false, 157 | }, 158 | { 159 | name: "Mixed Results Without notifySuccess", 160 | client: &testhelpers.SuccessStatusHTTPClient{}, 161 | results: mixedResults, 162 | failedCount: 1, 163 | instanceName: instanceName, 164 | notifySuccess: false, 165 | verbose: false, 166 | wantErr: false, 167 | }, 168 | { 169 | name: "Empty Instance Name", 170 | client: &testhelpers.SuccessStatusHTTPClient{}, 171 | results: failedResults, 172 | failedCount: 1, 173 | instanceName: "", 174 | notifySuccess: false, 175 | verbose: false, 176 | wantErr: false, 177 | }, 178 | { 179 | name: "Network Error When Pushing to Pushgateway", 180 | client: &testhelpers.FailureHTTPClient{}, 181 | results: failedResults, 182 | failedCount: 1, 183 | instanceName: instanceName, 184 | notifySuccess: false, 185 | verbose: false, 186 | wantErr: true, 187 | }, 188 | { 189 | name: "Unexpected Response Code From Pushgateway", 190 | client: &testhelpers.ErrorStatusHTTPClient{}, 191 | results: failedResults, 192 | failedCount: 1, 193 | instanceName: instanceName, 194 | notifySuccess: false, 195 | verbose: false, 196 | wantErr: true, 197 | }, 198 | { 199 | name: "Error Reading Response Body From Pushgateway", 200 | client: &testhelpers.InvalidResponseBodyHTTPClient{}, 201 | results: failedResults, 202 | failedCount: 1, 203 | instanceName: instanceName, 204 | notifySuccess: false, 205 | verbose: true, 206 | wantErr: true, 207 | }, 208 | } 209 | 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | cfg := newConfig(url, tt.instanceName, tt.notifySuccess, tt.verbose) 213 | sender, err := NewPushgatewaySender(tt.client, cfg) 214 | if err != nil { 215 | t.Fatalf("failed to create Pushgateway sender instance: %v", err) 216 | } 217 | 218 | err = sender.send(&tt.results, tt.failedCount) 219 | if tt.wantErr != (err != nil) { 220 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 221 | } 222 | }) 223 | } 224 | } 225 | 226 | func TestCreateMessage(t *testing.T) { 227 | cfg := &config.Config{ 228 | PushgatewayURL: "https://abc123.xyz.com", 229 | InstanceName: "test-instance", 230 | MachineNotifySuccess: false, 231 | Verbose: false, 232 | } 233 | 234 | sender, err := NewPushgatewaySender(&testhelpers.SuccessStatusHTTPClient{}, cfg) 235 | if err != nil { 236 | t.Fatalf("failed to create Pushgateway sender instance: %v", err) 237 | } 238 | 239 | failedCount := 1 240 | 241 | expected := ` 242 | #HELP failed sockets registered by dish 243 | #TYPE dish_failed_count counter 244 | dish_failed_count 1 245 | 246 | ` 247 | 248 | actual, err := sender.createMessage(failedCount) 249 | if err != nil { 250 | t.Errorf("error creating Pushgateway message: %v", err) 251 | } 252 | 253 | if expected != actual { 254 | t.Errorf("expected %s, got %s", expected, actual) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /pkg/alert/telegram.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | ) 11 | 12 | const ( 13 | baseURL = "https://api.telegram.org" 14 | messageTitle = "\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 | } 24 | 25 | func NewTelegramSender(httpClient HTTPClient, config *config.Config) *telegramSender { 26 | return &telegramSender{ 27 | httpClient, 28 | config.TelegramChatID, 29 | config.TelegramBotToken, 30 | config.Verbose, 31 | config.TextNotifySuccess, 32 | } 33 | } 34 | 35 | func (s *telegramSender) send(rawMessage string, failedCount int) error { 36 | // If no checks failed and success should not be notified, there is nothing to send 37 | if failedCount == 0 && !s.notifySuccess { 38 | if s.verbose { 39 | log.Printf("no sockets failed, nothing will be sent to Telegram") 40 | } 41 | return nil 42 | } 43 | 44 | // Construct the Telegram URL with params and the message 45 | telegramURL := fmt.Sprintf("%s/bot%s/sendMessage", baseURL, s.token) 46 | 47 | params := url.Values{} 48 | params.Set("chat_id", s.chatID) 49 | params.Set("disable_web_page_preview", "true") 50 | params.Set("parse_mode", "HTML") 51 | params.Set("text", messageTitle+"\n\n"+rawMessage) 52 | 53 | fullURL := telegramURL + "?" + params.Encode() 54 | 55 | err := handleSubmit(s.httpClient, http.MethodGet, fullURL, nil) 56 | if err != nil { 57 | return fmt.Errorf("error submitting Telegram alert: %w", err) 58 | } 59 | 60 | log.Println("Telegram alert sent") 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/alert/telegram_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | "go.vxn.dev/dish/pkg/testhelpers" 9 | ) 10 | 11 | func TestNewTelegramSender(t *testing.T) { 12 | mockHTTPClient := &testhelpers.SuccessStatusHTTPClient{} 13 | 14 | chatID := "-123" 15 | token := "abc123" 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 | } 26 | 27 | cfg := &config.Config{ 28 | TelegramChatID: chatID, 29 | TelegramBotToken: token, 30 | Verbose: verbose, 31 | TextNotifySuccess: notifySuccess, 32 | } 33 | 34 | actual := NewTelegramSender(mockHTTPClient, cfg) 35 | 36 | if !reflect.DeepEqual(expected, actual) { 37 | t.Fatalf("expected %v, got %v", expected, actual) 38 | } 39 | } 40 | 41 | func TestSend_Telegram(t *testing.T) { 42 | newConfig := func(chatID, token string, verbose, notifySuccess bool) *config.Config { 43 | return &config.Config{ 44 | TelegramChatID: chatID, 45 | TelegramBotToken: token, 46 | Verbose: verbose, 47 | TextNotifySuccess: notifySuccess, 48 | } 49 | } 50 | 51 | tests := []struct { 52 | name string 53 | client HTTPClient 54 | rawMessage string 55 | failedCount int 56 | notifySuccess bool 57 | verbose bool 58 | wantErr bool 59 | }{ 60 | { 61 | name: "Failed Sockets", 62 | client: &testhelpers.SuccessStatusHTTPClient{}, 63 | rawMessage: "Test message", 64 | failedCount: 1, 65 | notifySuccess: false, 66 | verbose: false, 67 | wantErr: false, 68 | }, 69 | { 70 | name: "Failed Sockets - Verbose", 71 | client: &testhelpers.SuccessStatusHTTPClient{}, 72 | rawMessage: "Test message", 73 | failedCount: 1, 74 | notifySuccess: false, 75 | verbose: true, 76 | wantErr: false, 77 | }, 78 | { 79 | name: "No Failed Sockets with notifySuccess", 80 | client: &testhelpers.SuccessStatusHTTPClient{}, 81 | rawMessage: "Test message", 82 | failedCount: 0, 83 | notifySuccess: true, 84 | verbose: false, 85 | wantErr: false, 86 | }, 87 | { 88 | name: "No Failed Sockets without notifySuccess", 89 | client: &testhelpers.SuccessStatusHTTPClient{}, 90 | rawMessage: "Test message", 91 | failedCount: 0, 92 | notifySuccess: false, 93 | verbose: false, 94 | wantErr: false, 95 | }, 96 | { 97 | name: "No Failed Sockets without notifySuccess - Verbose", 98 | client: &testhelpers.SuccessStatusHTTPClient{}, 99 | rawMessage: "Test message", 100 | failedCount: 0, 101 | notifySuccess: false, 102 | verbose: true, 103 | wantErr: false, 104 | }, 105 | { 106 | name: "Network Error When Sending Telegram Message", 107 | client: &testhelpers.FailureHTTPClient{}, 108 | rawMessage: "Test message", 109 | failedCount: 1, 110 | notifySuccess: false, 111 | verbose: false, 112 | wantErr: true, 113 | }, 114 | { 115 | name: "Unexpected Response Code From Telegram", 116 | client: &testhelpers.ErrorStatusHTTPClient{}, 117 | rawMessage: "Test message", 118 | failedCount: 1, 119 | notifySuccess: false, 120 | verbose: false, 121 | wantErr: true, 122 | }, 123 | { 124 | name: "Error Reading Response Body From Telegram", 125 | client: &testhelpers.InvalidResponseBodyHTTPClient{}, 126 | rawMessage: "Test message", 127 | failedCount: 1, 128 | notifySuccess: false, 129 | verbose: true, 130 | wantErr: true, 131 | }, 132 | } 133 | 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | cfg := newConfig("-123", "abc123", tt.verbose, tt.notifySuccess) 137 | sender := NewTelegramSender(tt.client, cfg) 138 | 139 | err := sender.send(tt.rawMessage, tt.failedCount) 140 | 141 | if tt.wantErr != (err != nil) { 142 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 143 | } 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /pkg/alert/transport.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // submitOptions holds optional parameters for submitting HTTP requests using handleSubmit. 11 | type submitOptions struct { 12 | contentType string 13 | headers map[string]string 14 | } 15 | 16 | // withContentType sets the provided contentType as the value of the Content-Type header. 17 | func withContentType(contentType string) func(*submitOptions) { 18 | return func(opts *submitOptions) { 19 | opts.contentType = contentType 20 | } 21 | } 22 | 23 | // withHeader adds the provided key:value header pair to the request's HTTP headers. 24 | func withHeader(key string, value string) func(*submitOptions) { 25 | return func(opts *submitOptions) { 26 | if opts.headers == nil { 27 | opts.headers = make(map[string]string) 28 | } 29 | opts.headers[key] = value 30 | } 31 | } 32 | 33 | // 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). 34 | // 35 | // By default, the application/json Content-Type header is used. A different content type can be specified using the withContentType functional option. 36 | // Custom header key:value pairs can be specified using the withHeader functional option. 37 | // 38 | // The response status code is checked and if it is not within the range of success codes (2xx), the response body is logged and an error with the received status code is returned. 39 | func handleSubmit(client HTTPClient, method string, url string, body io.Reader, opts ...func(*submitOptions)) error { 40 | // Default options 41 | options := submitOptions{ 42 | contentType: "application/json", 43 | headers: make(map[string]string), 44 | } 45 | 46 | // Apply provided options to the defaults 47 | for _, opt := range opts { 48 | opt(&options) 49 | } 50 | 51 | // Prepare the request 52 | req, err := http.NewRequest(method, url, body) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // Set content type 58 | req.Header.Set("Content-Type", options.contentType) 59 | 60 | // Apply provided custom headers 61 | for k, v := range options.headers { 62 | req.Header.Set(k, v) 63 | } 64 | 65 | // Submit the request 66 | res, err := client.Do(req) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | defer res.Body.Close() 72 | 73 | // If status code is not within <200, 299>, log the body and return an error with the received status code 74 | if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { 75 | body, err := io.ReadAll(res.Body) 76 | if err != nil { 77 | log.Printf("error reading response body: %v", err) 78 | } else { 79 | log.Printf("response from %s: %s", url, string(body)) 80 | } 81 | return fmt.Errorf("unexpected response code received (expected: %d, got: %d)", http.StatusOK, res.StatusCode) 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /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 | // Parse the provided URL 24 | parsedURL, err := url.ParseRequestURI(rawURL) 25 | if err != nil { 26 | return nil, fmt.Errorf("error parsing URL: %w", err) 27 | } 28 | 29 | // Validate the parsed URL 30 | switch { 31 | case parsedURL.Scheme == "": 32 | return nil, fmt.Errorf("protocol must be specified in the provided URL (e.g. https://...)") 33 | 34 | case !slices.Contains(supportedSchemes, parsedURL.Scheme): 35 | return nil, fmt.Errorf("unsupported protocol provided in URL: %s (supported protocols: %v)", parsedURL.Scheme, supportedSchemes) 36 | 37 | case parsedURL.Host == "": 38 | return nil, fmt.Errorf("URL must contain a host") 39 | } 40 | 41 | return parsedURL, nil 42 | } 43 | -------------------------------------------------------------------------------- /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/webhook.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "go.vxn.dev/dish/pkg/config" 11 | ) 12 | 13 | type webhookSender struct { 14 | httpClient HTTPClient 15 | url string 16 | verbose bool 17 | notifySuccess bool 18 | } 19 | 20 | func NewWebhookSender(httpClient HTTPClient, config *config.Config) (*webhookSender, error) { 21 | parsedURL, err := parseAndValidateURL(config.WebhookURL, nil) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return &webhookSender{ 27 | httpClient: httpClient, 28 | url: parsedURL.String(), 29 | verbose: config.Verbose, 30 | notifySuccess: config.MachineNotifySuccess, 31 | }, nil 32 | } 33 | 34 | func (s *webhookSender) send(m *Results, failedCount int) error { 35 | // If no checks failed and success should not be notified, there is nothing to send 36 | if failedCount == 0 && !s.notifySuccess { 37 | if s.verbose { 38 | log.Printf("no sockets failed, nothing will be sent to webhook") 39 | } 40 | return nil 41 | } 42 | 43 | jsonData, err := json.Marshal(m) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | bodyReader := bytes.NewReader(jsonData) 49 | 50 | if s.verbose { 51 | log.Printf("prepared webhook data: %s", string(jsonData)) 52 | } 53 | 54 | err = handleSubmit(s.httpClient, http.MethodPost, s.url, bodyReader) 55 | if err != nil { 56 | return fmt.Errorf("error pushing results to webhook: %w", err) 57 | } 58 | 59 | log.Println("results pushed to webhook") 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/alert/webhook_test.go: -------------------------------------------------------------------------------- 1 | package alert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | "go.vxn.dev/dish/pkg/testhelpers" 9 | ) 10 | 11 | func TestNewWebhookSender(t *testing.T) { 12 | mockHTTPClient := &testhelpers.SuccessStatusHTTPClient{} 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 | } 24 | 25 | cfg := &config.Config{ 26 | WebhookURL: url, 27 | Verbose: verbose, 28 | MachineNotifySuccess: notifySuccess, 29 | } 30 | actual, _ := NewWebhookSender(mockHTTPClient, cfg) 31 | 32 | if !reflect.DeepEqual(expected, actual) { 33 | t.Fatalf("expected %v, got %v", expected, actual) 34 | } 35 | } 36 | 37 | func TestSend_Webhook(t *testing.T) { 38 | url := "https://abc123.xyz.com" 39 | 40 | successResults := Results{ 41 | Map: map[string]bool{ 42 | "test": true, 43 | }, 44 | } 45 | failedResults := Results{ 46 | Map: map[string]bool{ 47 | "test": false, 48 | }, 49 | } 50 | mixedResults := Results{ 51 | Map: map[string]bool{ 52 | "test1": true, 53 | "test2": false, 54 | }, 55 | } 56 | 57 | newConfig := func(url string, notifySuccess, verbose bool) *config.Config { 58 | return &config.Config{ 59 | WebhookURL: url, 60 | Verbose: verbose, 61 | MachineNotifySuccess: notifySuccess, 62 | } 63 | } 64 | 65 | tests := []struct { 66 | name string 67 | client HTTPClient 68 | results Results 69 | failedCount int 70 | notifySuccess bool 71 | verbose bool 72 | wantErr bool 73 | }{ 74 | { 75 | name: "Failed Sockets", 76 | client: &testhelpers.SuccessStatusHTTPClient{}, 77 | results: failedResults, 78 | failedCount: 1, 79 | notifySuccess: false, 80 | verbose: false, 81 | wantErr: false, 82 | }, 83 | { 84 | name: "Failed Sockets - Verbose", 85 | client: &testhelpers.SuccessStatusHTTPClient{}, 86 | results: failedResults, 87 | failedCount: 1, 88 | notifySuccess: false, 89 | verbose: true, 90 | wantErr: false, 91 | }, 92 | { 93 | name: "No Failed Sockets With notifySuccess", 94 | client: &testhelpers.SuccessStatusHTTPClient{}, 95 | results: successResults, 96 | failedCount: 0, 97 | notifySuccess: true, 98 | verbose: false, 99 | wantErr: false, 100 | }, 101 | { 102 | name: "No Failed Sockets Without notifySuccess", 103 | client: &testhelpers.SuccessStatusHTTPClient{}, 104 | results: successResults, 105 | failedCount: 0, 106 | notifySuccess: false, 107 | verbose: false, 108 | wantErr: false, 109 | }, 110 | { 111 | name: "No Failed Sockets Without notifySuccess - Verbose", 112 | client: &testhelpers.SuccessStatusHTTPClient{}, 113 | results: successResults, 114 | failedCount: 0, 115 | notifySuccess: false, 116 | verbose: true, 117 | wantErr: false, 118 | }, 119 | { 120 | name: "Mixed Results With notifySuccess", 121 | client: &testhelpers.SuccessStatusHTTPClient{}, 122 | results: mixedResults, 123 | failedCount: 1, 124 | notifySuccess: true, 125 | verbose: false, 126 | wantErr: false, 127 | }, 128 | { 129 | name: "Mixed Results Without notifySuccess", 130 | client: &testhelpers.SuccessStatusHTTPClient{}, 131 | results: mixedResults, 132 | failedCount: 1, 133 | notifySuccess: false, 134 | verbose: false, 135 | wantErr: false, 136 | }, 137 | { 138 | name: "Network Error When Pushing to Webhook", 139 | client: &testhelpers.FailureHTTPClient{}, 140 | results: failedResults, 141 | failedCount: 1, 142 | notifySuccess: false, 143 | verbose: false, 144 | wantErr: true, 145 | }, 146 | { 147 | name: "Unexpected Response Code From Webhook", 148 | client: &testhelpers.ErrorStatusHTTPClient{}, 149 | results: failedResults, 150 | failedCount: 1, 151 | notifySuccess: false, 152 | verbose: false, 153 | wantErr: true, 154 | }, 155 | { 156 | name: "Error Reading Response Body From Webhook", 157 | client: &testhelpers.InvalidResponseBodyHTTPClient{}, 158 | results: failedResults, 159 | failedCount: 1, 160 | notifySuccess: false, 161 | verbose: true, 162 | wantErr: true, 163 | }, 164 | } 165 | 166 | for _, tt := range tests { 167 | t.Run(tt.name, func(t *testing.T) { 168 | cfg := newConfig(url, tt.verbose, tt.notifySuccess) 169 | sender, err := NewWebhookSender(tt.client, cfg) 170 | if err != nil { 171 | t.Fatalf("failed to create Webhook sender instance: %v", err) 172 | } 173 | 174 | err = sender.send(&tt.results, tt.failedCount) 175 | if tt.wantErr != (err != nil) { 176 | t.Errorf("expected error: %v, got: %v", tt.wantErr, err) 177 | } 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /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 | } 30 | 31 | const ( 32 | defaultInstanceName = "generic-dish" 33 | defaultApiHeaderName = "" 34 | defaultApiHeaderValue = "" 35 | defaultApiCacheSockets = false 36 | defaultApiCacheDir = ".cache" 37 | defaultApiCacheTTLMinutes = 10 38 | defaultVerbose = false 39 | defaultPushgatewayURL = "" 40 | defaultTelegramBotToken = "" 41 | defaultTelegramChatID = "" 42 | defaultTimeoutSeconds = 10 43 | defaultApiURL = "" 44 | defaultWebhookURL = "" 45 | defaultTextNotifySuccess = false 46 | defaultMachineNotifySuccess = false 47 | ) 48 | 49 | // ErrNoSourceProvided is returned when no source of sockets is specified. 50 | var ErrNoSourceProvided = errors.New("no source provided") 51 | 52 | // defineFlags defines flags on the provided FlagSet. The values of the flags are stored in the provided Config when parsed. 53 | func defineFlags(fs *flag.FlagSet, cfg *Config) { 54 | // System flags 55 | fs.StringVar(&cfg.InstanceName, "name", defaultInstanceName, "a string, dish instance name") 56 | fs.UintVar(&cfg.TimeoutSeconds, "timeout", defaultTimeoutSeconds, "an int, timeout in seconds for http and tcp calls") 57 | fs.BoolVar(&cfg.Verbose, "verbose", defaultVerbose, "a bool, console stdout logging toggle") 58 | 59 | // Integration channels flags 60 | // 61 | // General: 62 | fs.BoolVar(&cfg.TextNotifySuccess, "textNotifySuccess", defaultTextNotifySuccess, "a bool, specifies whether successful checks with no failures should be reported to text channels") 63 | fs.BoolVar(&cfg.MachineNotifySuccess, "machineNotifySuccess", defaultMachineNotifySuccess, "a bool, specifies whether successful checks with no failures should be reported to machine channels") 64 | 65 | // API socket source: 66 | 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)") 67 | 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)") 68 | fs.BoolVar(&cfg.ApiCacheSockets, "cache", defaultApiCacheSockets, "a bool, specifies whether to cache the socket list fetched from the remote API source") 69 | fs.StringVar(&cfg.ApiCacheDirectory, "cacheDir", defaultApiCacheDir, "a string, specifies the directory used to cache the socket list fetched from the remote API source") 70 | fs.UintVar(&cfg.ApiCacheTTLMinutes, "cacheTTL", defaultApiCacheTTLMinutes, "an int, time duration (in minutes) for which the cached list of sockets is valid") 71 | 72 | // Pushgateway: 73 | fs.StringVar(&cfg.PushgatewayURL, "target", defaultPushgatewayURL, "a string, result update path/URL to pushgateway, plaintext/byte output") 74 | 75 | // Telegram: 76 | fs.StringVar(&cfg.TelegramBotToken, "telegramBotToken", defaultTelegramBotToken, "a string, Telegram bot private token") 77 | fs.StringVar(&cfg.TelegramChatID, "telegramChatID", defaultTelegramChatID, "a string, Telegram chat/channel ID") 78 | 79 | // API for pushing results: 80 | fs.StringVar(&cfg.ApiURL, "updateURL", defaultApiURL, "a string, API endpoint URL for pushing results") 81 | 82 | // Webhooks: 83 | fs.StringVar(&cfg.WebhookURL, "webhookURL", defaultWebhookURL, "a string, URL of webhook endpoint") 84 | } 85 | 86 | // NewConfig returns a new instance of Config. 87 | // 88 | // 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. 89 | func NewConfig(fs *flag.FlagSet, args []string) (*Config, error) { 90 | cfg := &Config{ 91 | InstanceName: defaultInstanceName, 92 | ApiHeaderName: defaultApiHeaderName, 93 | ApiHeaderValue: defaultApiHeaderValue, 94 | ApiCacheSockets: defaultApiCacheSockets, 95 | ApiCacheDirectory: defaultApiCacheDir, 96 | ApiCacheTTLMinutes: defaultApiCacheTTLMinutes, 97 | Verbose: defaultVerbose, 98 | PushgatewayURL: defaultPushgatewayURL, 99 | TelegramBotToken: defaultTelegramBotToken, 100 | TelegramChatID: defaultTelegramChatID, 101 | TimeoutSeconds: defaultTimeoutSeconds, 102 | ApiURL: defaultApiURL, 103 | WebhookURL: defaultWebhookURL, 104 | } 105 | 106 | defineFlags(fs, cfg) 107 | 108 | // Parse flags 109 | if err := fs.Parse(args); err != nil { 110 | return nil, fmt.Errorf("error parsing flags: %w", err) 111 | } 112 | 113 | // Parse args 114 | parsedArgs := flag.CommandLine.Args() 115 | 116 | // If no source is provided, return an error 117 | if len(parsedArgs) == 0 { 118 | return nil, ErrNoSourceProvided 119 | } 120 | // Otherwise, store the source in the config 121 | cfg.Source = parsedArgs[0] 122 | 123 | return cfg, nil 124 | } 125 | -------------------------------------------------------------------------------- /pkg/logger/console_logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | // consoleLogger logs output to stderr. 10 | type consoleLogger struct { 11 | stdLogger *log.Logger 12 | logLevel LogLevel 13 | } 14 | 15 | // NewConsoleLogger creates a new ConsoleLogger instance, 16 | // If verbose is true, log level is set to TRACE (otherwise to INFO). 17 | func NewConsoleLogger(verbose bool) *consoleLogger { 18 | l := &consoleLogger{ 19 | stdLogger: log.New(os.Stderr, "", log.LstdFlags), 20 | } 21 | 22 | l.logLevel = INFO 23 | if verbose { 24 | l.logLevel = TRACE 25 | } 26 | 27 | return l 28 | } 29 | 30 | // log prints a message if the current log level allows it. 31 | // It adds the passed prefix and formats the output if a format string is passed. 32 | func (l *consoleLogger) log(level LogLevel, prefix string, format string, v ...any) { 33 | if l.logLevel > level { 34 | return 35 | } 36 | 37 | msg := prefix + " " + fmt.Sprint(v...) 38 | if format != "" { 39 | msg = prefix + " " + fmt.Sprintf(format, v...) 40 | } 41 | 42 | l.stdLogger.Print(msg) 43 | 44 | if level == PANIC { 45 | panic(msg) 46 | } 47 | } 48 | 49 | func (l *consoleLogger) Trace(v ...any) { 50 | l.log(TRACE, "TRACE:", "", v...) 51 | } 52 | 53 | func (l *consoleLogger) Tracef(f string, v ...any) { 54 | l.log(TRACE, "TRACE:", f, v...) 55 | } 56 | 57 | func (l *consoleLogger) Debug(v ...any) { 58 | l.log(DEBUG, "DEBUG:", "", v...) 59 | } 60 | 61 | func (l *consoleLogger) Debugf(f string, v ...any) { 62 | l.log(DEBUG, "DEBUG:", f, v...) 63 | } 64 | 65 | func (l *consoleLogger) Info(v ...any) { 66 | l.log(INFO, "INFO:", "", v...) 67 | } 68 | 69 | func (l *consoleLogger) Infof(f string, v ...any) { 70 | l.log(INFO, "INFO:", f, v...) 71 | } 72 | 73 | func (l *consoleLogger) Warn(v ...any) { 74 | l.log(WARN, "WARN:", "", v...) 75 | } 76 | 77 | func (l *consoleLogger) Warnf(f string, v ...any) { 78 | l.log(WARN, "WARN:", f, v...) 79 | } 80 | 81 | func (l *consoleLogger) Error(v ...any) { 82 | l.log(ERROR, "ERROR:", "", v...) 83 | } 84 | 85 | func (l *consoleLogger) Errorf(f string, v ...any) { 86 | l.log(ERROR, "ERROR:", f, v...) 87 | } 88 | 89 | func (l *consoleLogger) Panic(v ...any) { 90 | l.log(PANIC, "PANIC:", "", v...) 91 | } 92 | 93 | func (l *consoleLogger) Panicf(f string, v ...any) { 94 | l.log(PANIC, "PANIC:", f, v...) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/logger/console_logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "testing" 7 | ) 8 | 9 | func TestNewConsoleLogger(t *testing.T) { 10 | t.Run("verbose mode on", func(t *testing.T) { 11 | logger := NewConsoleLogger(true) 12 | if logger.logLevel != TRACE { 13 | t.Errorf("expected loglevel %d, got %d", TRACE, logger.logLevel) 14 | } 15 | }) 16 | 17 | t.Run("verbose mode off", func(t *testing.T) { 18 | logger := NewConsoleLogger(false) 19 | if logger.logLevel != INFO { 20 | t.Errorf("expected loglevel %d, got %d", INFO, logger.logLevel) 21 | } 22 | }) 23 | } 24 | 25 | func TestConsoleLogger_log(t *testing.T) { 26 | var buf bytes.Buffer 27 | 28 | tests := []struct { 29 | name string 30 | logFunc func(*consoleLogger) 31 | logger *consoleLogger 32 | expected string 33 | }{ 34 | { 35 | name: "Info adds INFO prefix and joins arguments with spaces", 36 | logFunc: func(logger *consoleLogger) { 37 | logger.Info("hello", 123, 321) 38 | }, 39 | logger: &consoleLogger{ 40 | stdLogger: log.New(&buf, "", 0), 41 | logLevel: TRACE, 42 | }, 43 | expected: "INFO: hello123 321\n", 44 | }, 45 | { 46 | name: "Infof adds INFO prefix and formats string correctly", 47 | logFunc: func(logger *consoleLogger) { 48 | logger.Infof("hello %s !", "dish") 49 | }, 50 | logger: &consoleLogger{ 51 | stdLogger: log.New(&buf, "", 0), 52 | logLevel: TRACE, 53 | }, 54 | expected: "INFO: hello dish !\n", 55 | }, 56 | { 57 | name: "Debug does not print if logLevel is INFO", 58 | logFunc: func(logger *consoleLogger) { 59 | logger.Debug("should not print") 60 | }, 61 | logger: &consoleLogger{ 62 | stdLogger: log.New(&buf, "", 0), 63 | logLevel: INFO, 64 | }, 65 | expected: "", 66 | }, 67 | { 68 | name: "Debug adds DEBUG prefix", 69 | logFunc: func(logger *consoleLogger) { 70 | logger.Debug("debug") 71 | }, 72 | logger: &consoleLogger{ 73 | stdLogger: log.New(&buf, "", 0), 74 | logLevel: DEBUG, 75 | }, 76 | expected: "DEBUG: debug\n", 77 | }, 78 | { 79 | name: "Debugf adds DEBUG prefix and formats string correctly", 80 | logFunc: func(logger *consoleLogger) { 81 | logger.Debugf("debug %d", 1) 82 | }, 83 | logger: &consoleLogger{ 84 | stdLogger: log.New(&buf, "", 0), 85 | logLevel: DEBUG, 86 | }, 87 | expected: "DEBUG: debug 1\n", 88 | }, 89 | { 90 | name: "Warn prints with WARN prefix", 91 | logFunc: func(logger *consoleLogger) { 92 | logger.Warn("warn message") 93 | }, 94 | logger: &consoleLogger{ 95 | stdLogger: log.New(&buf, "", 0), 96 | logLevel: TRACE, 97 | }, 98 | expected: "WARN: warn message\n", 99 | }, 100 | { 101 | name: "Warnf prints formatted WARN message", 102 | logFunc: func(logger *consoleLogger) { 103 | logger.Warnf("warn %d", 42) 104 | }, 105 | logger: &consoleLogger{ 106 | stdLogger: log.New(&buf, "", 0), 107 | logLevel: TRACE, 108 | }, 109 | expected: "WARN: warn 42\n", 110 | }, 111 | { 112 | name: "Error prints with ERROR prefix", 113 | logFunc: func(logger *consoleLogger) { 114 | logger.Error("error") 115 | }, 116 | logger: &consoleLogger{ 117 | stdLogger: log.New(&buf, "", 0), 118 | logLevel: TRACE, 119 | }, 120 | expected: "ERROR: error\n", 121 | }, 122 | { 123 | name: "Errorf prints formatted ERROR message", 124 | logFunc: func(logger *consoleLogger) { 125 | logger.Errorf("fail %s", "here") 126 | }, 127 | logger: &consoleLogger{ 128 | stdLogger: log.New(&buf, "", 0), 129 | logLevel: TRACE, 130 | }, 131 | expected: "ERROR: fail here\n", 132 | }, 133 | { 134 | name: "Trace prints with TRACE prefix", 135 | logFunc: func(logger *consoleLogger) { 136 | logger.Trace("trace") 137 | }, 138 | logger: &consoleLogger{ 139 | stdLogger: log.New(&buf, "", 0), 140 | logLevel: TRACE, 141 | }, 142 | expected: "TRACE: trace\n", 143 | }, 144 | { 145 | name: "Tracef prints formatted TRACE message", 146 | logFunc: func(logger *consoleLogger) { 147 | logger.Tracef("trace %d", 1) 148 | }, 149 | logger: &consoleLogger{ 150 | stdLogger: log.New(&buf, "", 0), 151 | logLevel: TRACE, 152 | }, 153 | expected: "TRACE: trace 1\n", 154 | }, 155 | } 156 | 157 | for _, tt := range tests { 158 | buf.Reset() 159 | 160 | tt.logFunc(tt.logger) 161 | 162 | output := buf.String() 163 | 164 | if output != tt.expected { 165 | t.Errorf("expected %s, got %s", tt.expected, output) 166 | } 167 | } 168 | } 169 | 170 | func TestConsoleLogger_log_Panic(t *testing.T) { 171 | logger := NewConsoleLogger(true) 172 | 173 | defer func() { 174 | r := recover() 175 | if r == nil { 176 | t.Fatal("expected panic but did not get one") 177 | } 178 | 179 | expected := "PANIC: could not start dish" 180 | if r != expected { 181 | t.Fatalf("expected panic message %s, got %s", expected, r) 182 | } 183 | }() 184 | 185 | logger.Panic("could not start dish") 186 | } 187 | 188 | func TestConsoleLogger_log_Panicf(t *testing.T) { 189 | logger := NewConsoleLogger(true) 190 | 191 | defer func() { 192 | r := recover() 193 | if r == nil { 194 | t.Fatal("expected panic but did not get one") 195 | } 196 | 197 | expected := "PANIC: could not start dish" 198 | if r != expected { 199 | t.Fatalf("expected panic message %s, got %s", expected, r) 200 | } 201 | }() 202 | 203 | logger.Panicf("could not start %s", "dish") 204 | } 205 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | // LogLevel specifies a level from which logs are printed. 4 | type LogLevel int32 5 | 6 | const ( 7 | TRACE LogLevel = iota 8 | DEBUG 9 | INFO 10 | WARN 11 | ERROR 12 | PANIC 13 | ) 14 | 15 | // Logger interface defines methods for logging at various levels. 16 | type Logger interface { 17 | Trace(v ...any) 18 | Tracef(format string, v ...any) 19 | Debug(v ...any) 20 | Debugf(format string, v ...any) 21 | Info(v ...any) 22 | Infof(format string, v ...any) 23 | Warn(v ...any) 24 | Warnf(format string, v ...any) 25 | Error(v ...any) 26 | Errorf(format string, v ...any) 27 | Panic(v ...any) 28 | Panicf(format string, v ...any) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/netrunner/runner.go: -------------------------------------------------------------------------------- 1 | package netrunner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "regexp" 10 | "slices" 11 | "strconv" 12 | "sync" 13 | "time" 14 | 15 | "go.vxn.dev/dish/pkg/config" 16 | "go.vxn.dev/dish/pkg/socket" 17 | ) 18 | 19 | const agentVersion = "1.11" 20 | 21 | // RunSocketTest is intended to be invoked in a separate goroutine. 22 | // It runs a test for the given socket and sends the result through the given channel. 23 | // If the test fails to start, the error is logged to STDOUT and no result is 24 | // sent. On return, Done() is called on the WaitGroup and the channel is closed. 25 | func RunSocketTest(sock socket.Socket, out chan<- socket.Result, wg *sync.WaitGroup, cfg *config.Config) { 26 | defer wg.Done() 27 | defer close(out) 28 | 29 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second) 30 | defer cancel() 31 | 32 | runner, err := NewNetRunner(sock, cfg.Verbose) 33 | if err != nil { 34 | log.Printf("failed to test socket: %v", err.Error()) 35 | return 36 | } 37 | 38 | out <- runner.RunTest(ctx, sock) 39 | } 40 | 41 | // NetRunner is used to run tests for a socket. 42 | type NetRunner interface { 43 | RunTest(ctx context.Context, sock socket.Socket) socket.Result 44 | } 45 | 46 | // NewNetRunner determines the protocol used for the socket test and creates a 47 | // new NetRunner for it. 48 | // 49 | // Rules for the test method determination (first matching rule applies): 50 | // - If socket.Host starts with 'http://' or 'https://', a HTTP runner is returned. 51 | // - If socket.Port is between 1 and 65535, a TCP runner is returned. 52 | // - If socket.Host is not empty, an ICMP runner is returned. 53 | // - If none of the above conditions are met, a non-nil error is returned. 54 | func NewNetRunner(sock socket.Socket, verbose bool) (NetRunner, error) { 55 | exp, err := regexp.Compile("^(http|https)://") 56 | if err != nil { 57 | return nil, fmt.Errorf("regex compilation failed: %w", err) 58 | } 59 | 60 | if exp.MatchString(sock.Host) { 61 | return httpRunner{client: &http.Client{}, verbose: verbose}, nil 62 | } 63 | 64 | if sock.Port >= 1 && sock.Port <= 65535 { 65 | return tcpRunner{verbose: verbose}, nil 66 | } 67 | 68 | if sock.Host != "" { 69 | return icmpRunner{verbose: verbose}, nil 70 | } 71 | 72 | return nil, fmt.Errorf("no protocol could be determined from the socket %s", sock.ID) 73 | } 74 | 75 | type tcpRunner struct { 76 | verbose bool 77 | } 78 | 79 | // RunTest is used to test TCP sockets. It opens a TCP connection with the given socket. 80 | // The test passes if the connection is successfully opened with no errors. 81 | func (runner tcpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 82 | endpoint := net.JoinHostPort(sock.Host, strconv.Itoa(sock.Port)) 83 | 84 | if runner.verbose { 85 | log.Println("TCP runner: connect: " + endpoint) 86 | } 87 | 88 | d := net.Dialer{} 89 | 90 | conn, err := d.DialContext(ctx, "tcp", endpoint) 91 | if err != nil { 92 | return socket.Result{Socket: sock, Error: err, Passed: false} 93 | } 94 | defer conn.Close() 95 | 96 | return socket.Result{Socket: sock, Passed: true} 97 | } 98 | 99 | type httpRunner struct { 100 | client *http.Client 101 | verbose bool 102 | } 103 | 104 | // RunTest is used to test HTTP/S endpoints exclusively. It executes a HTTP GET 105 | // request to the given socket. The test passes if the request did not end with 106 | // an error and the response status matches the expected HTTP codes. 107 | func (runner httpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 108 | url := sock.Host + ":" + strconv.Itoa(sock.Port) + sock.PathHTTP 109 | 110 | if runner.verbose { 111 | log.Println("HTTP runner: connect:", url) 112 | } 113 | 114 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 115 | if err != nil { 116 | return socket.Result{Socket: sock, Passed: false, Error: err} 117 | } 118 | req.Header.Set("User-Agent", fmt.Sprintf("dish/%s", agentVersion)) 119 | 120 | resp, err := runner.client.Do(req) 121 | if err != nil { 122 | return socket.Result{Socket: sock, Passed: false, Error: err} 123 | } 124 | defer resp.Body.Close() 125 | 126 | if !slices.Contains(sock.ExpectedHTTPCodes, resp.StatusCode) { 127 | err = fmt.Errorf("expected codes: %v, got %d", sock.ExpectedHTTPCodes, resp.StatusCode) 128 | } 129 | 130 | return socket.Result{ 131 | Socket: sock, 132 | Passed: slices.Contains(sock.ExpectedHTTPCodes, resp.StatusCode), 133 | ResponseCode: resp.StatusCode, 134 | Error: err, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /pkg/netrunner/runner_posix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package netrunner 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "errors" 9 | "fmt" 10 | "log" 11 | "net" 12 | "syscall" 13 | "time" 14 | 15 | "go.vxn.dev/dish/pkg/socket" 16 | ) 17 | 18 | type icmpRunner struct { 19 | verbose bool 20 | } 21 | 22 | // RunTest is used to test ICMP sockets. It sends an ICMP Echo Request to the given socket using 23 | // non-privileged ICMP and verifies the reply. The test passes if the reply has the same payload 24 | // as the request. Returns an error if the socket host cannot be resolved to an IPv4 address. If 25 | // the host resolves to more than one address, only the first one is used. 26 | func (runner icmpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 27 | if runner.verbose { 28 | log.Printf("Resolving host '%s' to an IP address", sock.Host) 29 | } 30 | 31 | addr, err := net.DefaultResolver.LookupIP(ctx, "ip4", sock.Host) 32 | if err != nil { 33 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to resolve socket host: %w", err)} 34 | } 35 | 36 | ip := addr[0] 37 | 38 | sockAddr := &syscall.SockaddrInet4{Addr: [4]byte(ip)} 39 | 40 | // When using ICMP over DGRAM, Linux Kernel automatically sets (overwrites) and 41 | // validates the id, seq and checksum of each incoming and outgoing ICMP message. 42 | // This is largely non-documented in the linux man pages. The closest I found is: 43 | // - (Linux news) lwn.net/Articles/420800/ 44 | // - (MacOS man) https://www.manpagez.com/man/4/icmp/ 45 | // - (Third-party article) https://inc0x0.com/icmp-ip-packets-ping-manually-create-and-send-icmp-ip-packets/ 46 | // "[...] most Linux systems use a unique identifier for every ping process, and sequence 47 | // number is an increasing number within that process. Windows uses a fixed identifier, which 48 | // varies between Windows versions, and a sequence number that is only reset at boot time." 49 | sysSocket, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, syscall.IPPROTO_ICMP) 50 | if err != nil { 51 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to create a non-privileged icmp socket: %w", err)} 52 | } 53 | defer syscall.Close(sysSocket) 54 | 55 | if d, ok := ctx.Deadline(); ok { 56 | // Set a socket receive timeout. 57 | t := syscall.NsecToTimeval(time.Until(d).Nanoseconds()) 58 | if err := syscall.SetsockoptTimeval(sysSocket, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &t); err != nil { 59 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to set a timeout on a non-privileged icmp socket: %w", err)} 60 | } 61 | } 62 | 63 | payload := []byte("ICMP echo") 64 | 65 | // ICMP Header size is 8 bytes. 66 | reqBuf := make([]byte, 8+len(payload)) 67 | 68 | // ICMP Header. 69 | // ID, Seq and Checksum are filled in automatically by the kernel. 70 | reqBuf[0] = 8 // Type: Echo 71 | 72 | copy(reqBuf[8:], payload) 73 | 74 | if runner.verbose { 75 | log.Println("ICMP runner: send to " + ip.String()) 76 | } 77 | 78 | if err := syscall.Sendto(sysSocket, reqBuf, 0, sockAddr); err != nil { 79 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to send an echo request: %w", err)} 80 | } 81 | 82 | // Maximum Transmission Unit (MTU) equals 1500 bytes. 83 | // Recvfrom before writing to the buffer, checks its length (not capacity). 84 | // If the length of the buffer is too small to fit the data then it's silently truncated. 85 | replyBuf := make([]byte, 1500) 86 | 87 | if runner.verbose { 88 | log.Println("ICMP runner: recv from " + ip.String()) 89 | } 90 | 91 | n, _, err := syscall.Recvfrom(sysSocket, replyBuf, 0) 92 | if err != nil { 93 | return socket.Result{Socket: sock, Error: fmt.Errorf("failed to receive a reply from a socket: %w", err)} 94 | } 95 | 96 | if n < 8 { 97 | return socket.Result{Socket: sock, Error: fmt.Errorf("reply is too short: received %d bytes ", n)} 98 | } 99 | 100 | if replyBuf[0] != 0 { 101 | return socket.Result{Socket: sock, Error: errors.New("received unexpected reply type")} 102 | } 103 | 104 | if !bytes.Equal(reqBuf[8:], replyBuf[8:n]) { 105 | return socket.Result{Socket: sock, Error: errors.New("failed to validate echo reply: payloads are not equal")} 106 | } 107 | 108 | return socket.Result{Socket: sock, Passed: true} 109 | } 110 | -------------------------------------------------------------------------------- /pkg/netrunner/runner_test.go: -------------------------------------------------------------------------------- 1 | package netrunner 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "reflect" 8 | "runtime" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/google/go-cmp/cmp/cmpopts" 15 | "go.vxn.dev/dish/pkg/config" 16 | "go.vxn.dev/dish/pkg/socket" 17 | ) 18 | 19 | // TestRunSocketTest is an integration test. It executes network calls to 20 | // external public servers. 21 | func TestRunSocketTest(t *testing.T) { 22 | t.Run("output chan is closed and the wait group is not blocking after a successful concurrent test", func(t *testing.T) { 23 | sock := socket.Socket{ 24 | ID: "google_tcp", 25 | Name: "Google TCP", 26 | Host: "google.com", 27 | Port: 80, 28 | } 29 | 30 | want := socket.Result{ 31 | Socket: sock, 32 | Passed: true, 33 | } 34 | 35 | c := make(chan socket.Result) 36 | wg := &sync.WaitGroup{} 37 | cfg, err := config.NewConfig(flag.CommandLine, []string{"--timeout=1", "--verbose=false", "mocksource.json"}) 38 | if err != nil { 39 | t.Fatalf("unexpected error creating config: %v", err) 40 | } 41 | done := make(chan struct{}) 42 | 43 | wg.Add(1) 44 | go RunSocketTest(sock, c, wg, cfg) 45 | 46 | go func() { 47 | wg.Wait() 48 | done <- struct{}{} 49 | }() 50 | 51 | got := <-c 52 | 53 | select { 54 | case <-done: 55 | case <-time.After(time.Second): 56 | t.Fatalf("RunSocketTest: timed out waiting for the test results") 57 | } 58 | 59 | select { 60 | // Once the test is finished no further results are sent. 61 | // If this select case blocks instead of reading the default value immediately then the channel is not closed. 62 | case <-c: 63 | default: 64 | t.Error("RunSocketTest: the output channel has not been closed after returning") 65 | } 66 | 67 | if !cmp.Equal(got, want) { 68 | t.Fatalf("RunSocketTest:\n want = %v\n got = %v\n", want, got) 69 | } 70 | }) 71 | } 72 | 73 | func TestNewNetRunner(t *testing.T) { 74 | type args struct { 75 | verbose bool 76 | sock socket.Socket 77 | } 78 | tests := []struct { 79 | name string 80 | args args 81 | want NetRunner 82 | wantErr bool 83 | }{ 84 | { 85 | name: "returns an error on an empty socket", 86 | args: args{ 87 | verbose: testing.Verbose(), 88 | sock: socket.Socket{}, 89 | }, 90 | wantErr: true, 91 | }, 92 | { 93 | name: "returns an httpRunner when given an HTTPs socket", 94 | args: args{ 95 | verbose: testing.Verbose(), 96 | sock: socket.Socket{ 97 | ID: "google_https", 98 | Name: "Google HTTPs", 99 | Host: "https://google.com", 100 | Port: 443, 101 | ExpectedHTTPCodes: []int{200}, 102 | PathHTTP: "/", 103 | }, 104 | }, 105 | want: httpRunner{ 106 | client: &http.Client{}, 107 | verbose: testing.Verbose(), 108 | }, 109 | wantErr: false, 110 | }, 111 | { 112 | name: "returns an httpRunner when given a HTTP socket", 113 | args: args{ 114 | verbose: testing.Verbose(), 115 | sock: socket.Socket{ 116 | ID: "google_http", 117 | Name: "Google HTTP", 118 | Host: "http://www.google.com", 119 | Port: 80, 120 | ExpectedHTTPCodes: []int{200}, 121 | PathHTTP: "/", 122 | }, 123 | }, 124 | want: httpRunner{ 125 | client: &http.Client{}, 126 | verbose: testing.Verbose(), 127 | }, 128 | wantErr: false, 129 | }, 130 | { 131 | name: "returns a tcpRunner when given a TCP socket", 132 | args: args{ 133 | verbose: testing.Verbose(), 134 | sock: socket.Socket{ 135 | Port: 80, 136 | ExpectedHTTPCodes: []int{200}, 137 | PathHTTP: "/", 138 | }, 139 | }, 140 | want: tcpRunner{verbose: testing.Verbose()}, 141 | wantErr: false, 142 | }, 143 | { 144 | name: "returns an icmpRunner when given an ICMP socket", 145 | args: args{ 146 | verbose: testing.Verbose(), 147 | sock: socket.Socket{ 148 | Host: "google.com", 149 | }, 150 | }, 151 | want: icmpRunner{verbose: testing.Verbose()}, 152 | wantErr: false, 153 | }, 154 | } 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | got, err := NewNetRunner(tt.args.sock, tt.args.verbose) 158 | if (err != nil) != tt.wantErr { 159 | t.Fatalf("NewNetRunner():\n error = %v\n wantErr = %v", err, tt.wantErr) 160 | } 161 | 162 | if tt.wantErr { 163 | return 164 | } 165 | 166 | if !reflect.DeepEqual(got, tt.want) { 167 | t.Fatalf("NewNetRunner():\n got = %v\n want = %v", got, tt.want) 168 | } 169 | }) 170 | } 171 | } 172 | 173 | // TestTcpRunner_RunTest is an integration test. It executes network calls to 174 | // external public servers. 175 | func TestTcpRunner_RunTest(t *testing.T) { 176 | type fields struct { 177 | verbose bool 178 | } 179 | type args struct { 180 | sock socket.Socket 181 | } 182 | tests := []struct { 183 | name string 184 | fields fields 185 | args args 186 | want socket.Result 187 | }{ 188 | { 189 | name: "returns a success on a call to a valid TCP server", 190 | fields: fields{ 191 | verbose: testing.Verbose(), 192 | }, 193 | args: args{ 194 | sock: socket.Socket{ 195 | ID: "google_tcp", 196 | Name: "Google TCP", 197 | Host: "google.com", 198 | Port: 80, 199 | }, 200 | }, 201 | want: socket.Result{ 202 | Socket: socket.Socket{ 203 | ID: "google_tcp", 204 | Name: "Google TCP", 205 | Host: "google.com", 206 | Port: 80, 207 | }, 208 | Passed: true, 209 | }, 210 | }, 211 | } 212 | for _, tt := range tests { 213 | t.Run(tt.name, func(t *testing.T) { 214 | r := tcpRunner{tt.fields.verbose} 215 | 216 | if got := r.RunTest(context.Background(), tt.args.sock); !cmp.Equal(got, tt.want) { 217 | t.Fatalf("tcpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 218 | } 219 | }) 220 | } 221 | } 222 | 223 | // TestHttpRunner_RunTest is an integration test. It executes network calls to 224 | // external public servers. 225 | func TestHttpRunner_RunTest(t *testing.T) { 226 | type args struct { 227 | sock socket.Socket 228 | } 229 | tests := []struct { 230 | name string 231 | runner httpRunner 232 | args args 233 | want socket.Result 234 | }{ 235 | { 236 | name: "returns a success on a call to a valid HTTPs server", 237 | runner: httpRunner{client: &http.Client{}, verbose: testing.Verbose()}, 238 | args: args{ 239 | sock: socket.Socket{ 240 | ID: "google_http", 241 | Name: "Google HTTP", 242 | Host: "https://www.google.com", 243 | Port: 443, 244 | ExpectedHTTPCodes: []int{200}, 245 | PathHTTP: "/", 246 | }, 247 | }, 248 | want: socket.Result{ 249 | Socket: socket.Socket{ 250 | ID: "google_http", 251 | Name: "Google HTTP", 252 | Host: "https://www.google.com", 253 | Port: 443, 254 | ExpectedHTTPCodes: []int{200}, 255 | PathHTTP: "/", 256 | }, 257 | Passed: true, 258 | ResponseCode: 200, 259 | }, 260 | }, 261 | { 262 | name: "returns a failure on a call to an invalid HTTPs server", 263 | // Since both DNS and HTTPs use TCP, the conn opens successfully but, 264 | // the request timeouts while awaiting HTTP headers. 265 | runner: httpRunner{client: &http.Client{Timeout: time.Second}, verbose: testing.Verbose()}, 266 | args: args{ 267 | sock: socket.Socket{ 268 | ID: "cloudflare_dns", 269 | Name: "Cloudflare DNS", 270 | Host: "https://1.1.1.1", 271 | Port: 53, 272 | ExpectedHTTPCodes: []int{200}, 273 | PathHTTP: "/", 274 | }, 275 | }, 276 | want: socket.Result{ 277 | Socket: socket.Socket{ 278 | ID: "cloudflare_dns", 279 | Name: "Cloudflare DNS", 280 | Host: "https://1.1.1.1", 281 | Port: 53, 282 | ExpectedHTTPCodes: []int{200}, 283 | PathHTTP: "/", 284 | }, 285 | Passed: false, 286 | Error: cmpopts.AnyError, 287 | }, 288 | }, 289 | } 290 | for _, tt := range tests { 291 | t.Run(tt.name, func(t *testing.T) { 292 | got := tt.runner.RunTest(context.Background(), tt.args.sock) 293 | if !cmp.Equal(got, tt.want, cmpopts.EquateErrors()) { 294 | t.Fatalf("httpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 295 | } 296 | }) 297 | } 298 | } 299 | 300 | // TestIcmpRunner_RunTest is an integration test. It executes network calls to 301 | // external public servers. 302 | // This test is common for all OS implementations except for Windows which is not supported. 303 | func TestIcmpRunner_RunTest(t *testing.T) { 304 | if runtime.GOOS == "windows" { 305 | t.Skip("ICMP tests are skipped on Windows") 306 | } 307 | 308 | type args struct { 309 | sock socket.Socket 310 | } 311 | tests := []struct { 312 | name string 313 | runner icmpRunner 314 | args args 315 | want socket.Result 316 | }{ 317 | { 318 | name: "returns a success on a call to a valid host", 319 | runner: icmpRunner{ 320 | verbose: testing.Verbose(), 321 | }, 322 | args: args{ 323 | sock: socket.Socket{ 324 | ID: "google_icmp", 325 | Name: "Google ICMP", 326 | Host: "google.com", 327 | }, 328 | }, 329 | want: socket.Result{ 330 | Socket: socket.Socket{ 331 | ID: "google_icmp", 332 | Name: "Google ICMP", 333 | Host: "google.com", 334 | }, 335 | Passed: true, 336 | }, 337 | }, 338 | { 339 | name: "returns a success on a call to a valid IP address", 340 | runner: icmpRunner{ 341 | verbose: testing.Verbose(), 342 | }, 343 | args: args{ 344 | sock: socket.Socket{ 345 | ID: "google_icmp", 346 | Name: "Google ICMP", 347 | Host: "8.8.8.8", 348 | }, 349 | }, 350 | want: socket.Result{ 351 | Socket: socket.Socket{ 352 | ID: "google_icmp", 353 | Name: "Google ICMP", 354 | Host: "8.8.8.8", 355 | }, 356 | Passed: true, 357 | }, 358 | }, 359 | { 360 | name: "returns an error on an empty host", 361 | runner: icmpRunner{ 362 | verbose: testing.Verbose(), 363 | }, 364 | args: args{ 365 | sock: socket.Socket{ 366 | ID: "empty_host", 367 | Name: "Empty Host", 368 | Host: "", 369 | }, 370 | }, 371 | want: socket.Result{ 372 | Socket: socket.Socket{ 373 | ID: "empty_host", 374 | Name: "Empty Host", 375 | Host: "", 376 | }, 377 | Passed: false, 378 | Error: cmpopts.AnyError, 379 | }, 380 | }, 381 | { 382 | name: "returns an error on an invalid IP address", 383 | runner: icmpRunner{ 384 | verbose: testing.Verbose(), 385 | }, 386 | args: args{ 387 | sock: socket.Socket{ 388 | ID: "invalid_ip", 389 | Name: "Invalid IP", 390 | Host: "256.100.50.25", 391 | }, 392 | }, 393 | want: socket.Result{ 394 | Socket: socket.Socket{ 395 | ID: "invalid_ip", 396 | Name: "Invalid IP", 397 | Host: "256.100.50.25", 398 | }, 399 | Passed: false, 400 | Error: cmpopts.AnyError, 401 | }, 402 | }, 403 | } 404 | for _, tt := range tests { 405 | t.Run(tt.name, func(t *testing.T) { 406 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 407 | defer cancel() 408 | 409 | got := tt.runner.RunTest(ctx, tt.args.sock) 410 | if !cmp.Equal(got, tt.want, cmpopts.EquateErrors()) { 411 | t.Fatalf("icmpRunner.RunTest():\n got = %v\n want = %v", got, tt.want) 412 | } 413 | }) 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /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/socket" 10 | ) 11 | 12 | type icmpRunner struct { 13 | verbose bool 14 | } 15 | 16 | func (runner icmpRunner) RunTest(ctx context.Context, sock socket.Socket) socket.Result { 17 | return socket.Result{Socket: sock, Error: errors.New("icmp tests on windows are not implemented")} 18 | } 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "go.vxn.dev/dish/pkg/testhelpers" 12 | ) 13 | 14 | func TestHashUrlToFilePath(t *testing.T) { 15 | tests := []struct { 16 | url string 17 | cacheDir string 18 | expected string 19 | }{ 20 | { 21 | "https://example.com", 22 | "test_cache", 23 | filepath.Join("test_cache", "327c3fda87ce286848a574982ddd0b7c7487f816.json"), 24 | }, 25 | { 26 | "http://localhost", 27 | "test_cache", 28 | filepath.Join("test_cache", "8523ab8065a69338d5006c34310dc8d2c0179ebb.json"), 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.url, func(t *testing.T) { 34 | got := hashUrlToFilePath(tt.url, tt.cacheDir) 35 | if got != tt.expected { 36 | t.Errorf("got %s, want %s", got, tt.expected) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestSaveSocketsToCache(t *testing.T) { 43 | filePath := testhelpers.TestFile(t, "randomhash.json", nil) 44 | cacheDir := filepath.Dir(filePath) 45 | 46 | if err := saveSocketsToCache(filePath, cacheDir, []byte(testhelpers.TestSocketList)); err != nil { 47 | t.Fatalf("expected no error, but got %v", err) 48 | } 49 | 50 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 51 | t.Fatalf("expected file %s to exist, but it does not", filePath) 52 | } 53 | 54 | readBytes, err := os.ReadFile(filePath) 55 | if err != nil { 56 | t.Fatalf("failed to read saved cache: %v", err) 57 | } 58 | 59 | if string(readBytes) != testhelpers.TestSocketList { 60 | t.Errorf("expected file content %s, got %s", testhelpers.TestSocketList, string(readBytes)) 61 | } 62 | } 63 | 64 | func TestLoadSocketsFromCache(t *testing.T) { 65 | t.Run("Load Sockets From Cache", func(t *testing.T) { 66 | filePath := testhelpers.TestFile(t, "randomhash.json", []byte(testhelpers.TestSocketList)) 67 | cacheTTL := uint(60) 68 | 69 | readerFromCache, _, err := loadCachedSockets(filePath, cacheTTL) 70 | if err != nil { 71 | t.Fatalf("expected no error, but got %v", err) 72 | } 73 | defer readerFromCache.Close() 74 | 75 | readBytes, err := io.ReadAll(readerFromCache) 76 | if err != nil { 77 | t.Fatalf("failed to read saved cache: %v", err) 78 | } 79 | 80 | if string(readBytes) != testhelpers.TestSocketList { 81 | t.Errorf("expected retrieved data to be %s, got %s", testhelpers.TestSocketList, string(readBytes)) 82 | } 83 | }) 84 | 85 | t.Run("Load Sockets From Expired Cache", func(t *testing.T) { 86 | filePath := testhelpers.TestFile(t, "randomhash.json", []byte(testhelpers.TestSocketList)) 87 | cacheTTL := uint(0) 88 | 89 | // 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. 90 | // Sleeping for a couple ms seems to have solved the issue. 91 | time.Sleep(200 * time.Millisecond) 92 | 93 | readerFromCache, _, err := loadCachedSockets(filePath, cacheTTL) 94 | if !errors.Is(err, ErrExpiredCache) { 95 | t.Errorf("expected error %v, but got %v", ErrExpiredCache, err) 96 | } 97 | defer readerFromCache.Close() 98 | 99 | readBytes, err := io.ReadAll(readerFromCache) 100 | if err != nil { 101 | t.Fatalf("failed to read saved cache: %v", err) 102 | } 103 | 104 | if string(readBytes) != testhelpers.TestSocketList { 105 | t.Errorf("expected retrieved data to be %s, got %s", testhelpers.TestSocketList, string(readBytes)) 106 | } 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/socket/fetch_local.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | 8 | "go.vxn.dev/dish/pkg/config" 9 | ) 10 | 11 | // fetchSocketsFromFile opens a file and returns [io.ReadCloser] for reading from the stream. 12 | func fetchSocketsFromFile(config *config.Config) (io.ReadCloser, error) { 13 | file, err := os.Open(config.Source) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | // TODO: Replace with logger 19 | if config.Verbose { 20 | log.Printf("fetching sockets from file (%s)", config.Source) 21 | } 22 | 23 | return file, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/socket/fetch_local_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "go.vxn.dev/dish/pkg/config" 8 | "go.vxn.dev/dish/pkg/testhelpers" 9 | ) 10 | 11 | func TestFetchSocketsFromFile(t *testing.T) { 12 | filePath := testhelpers.TestFile(t, "randomhash.json", []byte(testhelpers.TestSocketList)) 13 | cfg := &config.Config{ 14 | Source: filePath, 15 | } 16 | 17 | reader, err := fetchSocketsFromFile(cfg) 18 | if err != nil { 19 | t.Fatalf("Failed to fetch sockets from file %v\n", err) 20 | } 21 | defer reader.Close() 22 | 23 | fileData, err := io.ReadAll(reader) 24 | if err != nil { 25 | t.Fatalf("Failed to load data from file %v\n", err) 26 | } 27 | 28 | fileDataString := string(fileData) 29 | if fileDataString != testhelpers.TestSocketList { 30 | t.Errorf("Got %s, expected %s from file\n", fileDataString, testhelpers.TestSocketList) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/socket/fetch_remote.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "go.vxn.dev/dish/pkg/config" 12 | ) 13 | 14 | // copyBody copies the provided response body to the provided buffer. The body is closed. 15 | func copyBody(body io.ReadCloser, buf *bytes.Buffer) error { 16 | defer body.Close() 17 | 18 | _, err := buf.ReadFrom(body) 19 | return err 20 | } 21 | 22 | // 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. 23 | // 24 | // 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. 25 | // 26 | // The url parameter must be a complete URL to a remote http/s server, including: 27 | // - Scheme (http:// or https://) 28 | // - Host (domain or IP) 29 | // - Optional port 30 | // - Optional path 31 | // - Optional query parameters 32 | // 33 | // Example url: http://api.example.com:5569/stream?query=variable 34 | func fetchSocketsFromRemote(config *config.Config) (io.ReadCloser, error) { 35 | cacheFilePath := hashUrlToFilePath(config.Source, config.ApiCacheDirectory) 36 | 37 | // If we do not want to cache sockets to the file, fetch from network 38 | if !config.ApiCacheSockets { 39 | return loadFreshSockets(config) 40 | } 41 | 42 | // If cache is enabled, try to load sockets from it first 43 | cachedReader, cacheTime, err := loadCachedSockets(cacheFilePath, config.ApiCacheTTLMinutes) 44 | // If cache is expired or fails to load, attempt to fetch fresh sockets 45 | if err != nil { 46 | log.Printf("cache unavailable for URL: %s (reason: %v); attempting network fetch", config.Source, err) 47 | 48 | // Fetch fresh sockets from network 49 | respBody, fetchErr := loadFreshSockets(config) 50 | if fetchErr != nil { 51 | log.Printf("fetching socket list from remote API at %s failed: %v", config.Source, fetchErr) 52 | 53 | // If the fetch fails and expired cache is not available, return the fetch error 54 | if err != ErrExpiredCache { 55 | return nil, fetchErr 56 | } 57 | // If the fetch fails and expired cache is available, return the expired cache and log a warning 58 | log.Printf("using expired cache from %s", cacheTime.Format(time.RFC3339)) 59 | return cachedReader, nil 60 | } 61 | 62 | var buf bytes.Buffer 63 | err = copyBody(respBody, &buf) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to copy response body: %w", err) 66 | } 67 | 68 | if err := saveSocketsToCache(cacheFilePath, config.ApiCacheDirectory, buf.Bytes()); err != nil { 69 | log.Printf("failed to save fetched sockets to cache: %v", err) 70 | } 71 | 72 | return io.NopCloser(bytes.NewReader(buf.Bytes())), nil 73 | } 74 | 75 | // Cache is valid (not expired, no error from file read) 76 | log.Println("loading sockets from cache...") 77 | return cachedReader, err 78 | } 79 | 80 | // loadFreshSockets fetches fresh sockets from the remote source. 81 | func loadFreshSockets(config *config.Config) (io.ReadCloser, error) { 82 | req, err := http.NewRequest(http.MethodGet, config.Source, nil) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create HTTP request: %w", err) 85 | } 86 | 87 | client := &http.Client{} 88 | req.Header.Set("Content-Type", "application/json") 89 | 90 | if config.ApiHeaderName != "" && config.ApiHeaderValue != "" { 91 | req.Header.Set(config.ApiHeaderName, config.ApiHeaderValue) 92 | } 93 | 94 | resp, err := client.Do(req) 95 | if err != nil { 96 | return nil, fmt.Errorf("network request failed: %w", err) 97 | } 98 | 99 | if resp.StatusCode != http.StatusOK { 100 | return nil, fmt.Errorf("failed to fetch sockets from remote source --- got %d (%s)", resp.StatusCode, resp.Status) 101 | } 102 | 103 | return resp.Body, nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/socket/fetch_remote_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "path/filepath" 8 | "testing" 9 | 10 | "go.vxn.dev/dish/pkg/config" 11 | "go.vxn.dev/dish/pkg/testhelpers" 12 | ) 13 | 14 | func TestFetchSocketsFromRemote(t *testing.T) { 15 | apiHeaderName := "Authorization" 16 | apiHeaderValue := "Bearer xyzzzzzzz" 17 | mockServer := testhelpers.NewMockServer(t, apiHeaderName, apiHeaderValue, testhelpers.TestSocketList, http.StatusOK) 18 | 19 | newConfig := func(source string, useCache bool, ttl uint) *config.Config { 20 | // Temp cache directory needs to be created and specified for each test separately 21 | // See the range tests below 22 | return &config.Config{ 23 | Source: source, 24 | ApiCacheSockets: useCache, 25 | ApiCacheTTLMinutes: ttl, 26 | ApiHeaderName: apiHeaderName, 27 | ApiHeaderValue: apiHeaderValue, 28 | } 29 | } 30 | 31 | tests := []struct { 32 | name string 33 | cfg *config.Config 34 | expectedError bool 35 | }{ 36 | {"Fetch With Valid Cache", newConfig(mockServer.URL, true, 10), false}, 37 | {"Fetch With Expired Cache", newConfig(mockServer.URL, true, 0), false}, 38 | {"Fetch Without Caching", newConfig(mockServer.URL, false, 0), false}, 39 | {"Invalid URL Without Cache", newConfig("http://badurl.com", false, 0), true}, 40 | {"Invalid URL With Cache", newConfig("http://badurl.com", true, 0), true}, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | // Specify temp cache file & directory for each test separately 46 | // This fixes open file handles preventing the tests from succeeding on Windows 47 | filePath := testhelpers.TestFile(t, "randomhash.json", []byte(testhelpers.TestSocketList)) 48 | tt.cfg.ApiCacheDirectory = filepath.Dir(filePath) 49 | 50 | resp, err := fetchSocketsFromRemote(tt.cfg) 51 | if tt.expectedError { 52 | if err == nil || errors.Is(err, ErrExpiredCache) { 53 | t.Errorf("expected error, got %v", err) 54 | } 55 | return 56 | } 57 | if err != nil { 58 | t.Fatalf("expected no error, got %v", err) 59 | } 60 | 61 | readBytes, err := io.ReadAll(resp) 62 | if err != nil { 63 | t.Fatalf("failed to read from response: %v", err) 64 | } 65 | 66 | if string(readBytes) != testhelpers.TestSocketList { 67 | t.Errorf("expected %s, got %s", testhelpers.TestSocketList, string(readBytes)) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/socket/socket.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | 9 | "go.vxn.dev/dish/pkg/config" 10 | ) 11 | 12 | type Result struct { 13 | Socket Socket 14 | Passed bool 15 | ResponseCode int 16 | Error error 17 | } 18 | 19 | type SocketList struct { 20 | Sockets []Socket `json:"sockets"` 21 | } 22 | 23 | type Socket struct { 24 | // ID is an unique identifier of such socket. 25 | ID string `json:"id"` 26 | 27 | // Socket name, unique identificator, snake_cased. 28 | Name string `json:"socket_name"` 29 | 30 | // Remote endpoint hostname or URL. 31 | Host string `json:"host_name"` 32 | 33 | // Remote port to assemble a socket. 34 | Port int `json:"port_tcp"` 35 | 36 | // HTTP Status Codes expected when giving the endpoint a HEAD/GET request. 37 | ExpectedHTTPCodes []int `json:"expected_http_code_array"` 38 | 39 | // HTTP Path to test on Host. 40 | PathHTTP string `json:"path_http"` 41 | } 42 | 43 | // PrintSockets prints SocketList. 44 | func PrintSockets(list *SocketList) { 45 | log.Println("loaded sockets:") 46 | for _, socket := range list.Sockets { 47 | log.Printf("Host: %s, Port: %d, ExpectedHTTPCodes: %v", socket.Host, socket.Port, socket.ExpectedHTTPCodes) 48 | } 49 | } 50 | 51 | // LoadSocketList decodes a JSON encoded SocketList from the provided io.ReadCloser. 52 | func LoadSocketList(reader io.ReadCloser) (*SocketList, error) { 53 | defer reader.Close() 54 | 55 | list := new(SocketList) 56 | if err := json.NewDecoder(reader).Decode(list); err != nil { 57 | return nil, fmt.Errorf("error decoding sockets json: %w", err) 58 | } 59 | 60 | return list, nil 61 | } 62 | 63 | // FetchSocketList fetches the list of sockets to be checked. 'input' should be a string like '/path/filename.json', or an HTTP URL string. 64 | func FetchSocketList(config *config.Config) (*SocketList, error) { 65 | var reader io.ReadCloser 66 | var err error 67 | 68 | if IsFilePath(config.Source) { 69 | reader, err = fetchSocketsFromFile(config) 70 | } else { 71 | reader, err = fetchSocketsFromRemote(config) 72 | } 73 | 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return LoadSocketList(reader) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/socket/socket_test.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "net/http" 8 | "reflect" 9 | "testing" 10 | 11 | "go.vxn.dev/dish/pkg/config" 12 | "go.vxn.dev/dish/pkg/testhelpers" 13 | ) 14 | 15 | func TestPrintSockets(t *testing.T) { 16 | list := &SocketList{ 17 | Sockets: []Socket{ 18 | {ID: "1", Name: "socket", Host: "example.com", Port: 80, ExpectedHTTPCodes: []int{200, 404}}, 19 | }, 20 | } 21 | 22 | var buf bytes.Buffer 23 | log.SetOutput(&buf) 24 | 25 | PrintSockets(list) 26 | 27 | expected := "Host: example.com, Port: 80, ExpectedHTTPCodes: [200 404]\n" 28 | if !bytes.Contains(buf.Bytes(), []byte(expected)) { 29 | t.Errorf("Expected TestPrintSockets() to contain %s, but got %s", expected, buf.String()) 30 | } 31 | } 32 | 33 | func TestLoadSocketList(t *testing.T) { 34 | tests := []struct { 35 | name string 36 | json string 37 | expectErr bool 38 | }{ 39 | { 40 | "Valid JSON", 41 | testhelpers.TestSocketList, 42 | false, 43 | }, 44 | { 45 | "Invalid JSON", 46 | `{ "sockets": [ { "id": "vxn_dev_https"`, 47 | true, 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | reader := io.NopCloser(bytes.NewReader([]byte(tt.json))) 54 | if _, err := LoadSocketList(reader); (err == nil) == tt.expectErr { 55 | t.Errorf("Expect error: %v, got error: %v\n", tt.expectErr, err) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestFetchSocketList(t *testing.T) { 62 | mockServer := testhelpers.NewMockServer(t, "", "", testhelpers.TestSocketList, http.StatusOK) 63 | validFile := testhelpers.TestFile(t, "randomhash.json", []byte(testhelpers.TestSocketList)) 64 | socketStringReader := io.NopCloser(bytes.NewBufferString(testhelpers.TestSocketList)) 65 | originalList, err := LoadSocketList(socketStringReader) 66 | if err != nil { 67 | t.Fatalf("failed to parse sockets string to an object: %v", err) 68 | } 69 | 70 | newConfig := func(source string) *config.Config { 71 | return &config.Config{ 72 | Source: source, 73 | } 74 | } 75 | 76 | tests := []struct { 77 | name string 78 | source string 79 | expectError bool 80 | }{ 81 | { 82 | name: "Fetch from file", 83 | source: validFile, 84 | expectError: false, 85 | }, 86 | { 87 | name: "Fetch from remote", 88 | source: mockServer.URL, 89 | expectError: false, 90 | }, 91 | { 92 | name: "Fetch from remote with bad URL", 93 | source: "http://invalid-host.local", 94 | expectError: true, 95 | }, 96 | { 97 | name: "Fetch from not existent file", 98 | source: "thisdoesntexist.json", 99 | expectError: true, 100 | }, 101 | } 102 | 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | cfg := newConfig(tt.source) 106 | 107 | fetchedList, err := FetchSocketList(cfg) 108 | if tt.expectError { 109 | if err == nil { 110 | t.Errorf("expected error, got %v", err) 111 | } 112 | return 113 | } 114 | 115 | if err != nil { 116 | t.Fatalf("expected no error, got %v", err) 117 | } 118 | 119 | // Manual comparison of 2 objects won't work because of expected codes type ([]int) in Socket struct 120 | if !reflect.DeepEqual(fetchedList, originalList) { 121 | t.Errorf("expected %+v, got %+v", originalList, fetchedList) 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/testhelpers/helpers.go: -------------------------------------------------------------------------------- 1 | package testhelpers 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 TestSocketList 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, filename string, data []byte) string { 17 | t.Helper() 18 | dir := t.TempDir() 19 | 20 | filepath := filepath.Join(dir, filename) 21 | 22 | err := os.WriteFile(filepath, data, 0o600) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | return filepath 28 | } 29 | 30 | // NewMockServer creates an httptest.Server that simulates an expected API endpoint. 31 | // It validates a specific request header (if provided) and returns a customizable response. 32 | func NewMockServer(t *testing.T, expectedHeaderName, expectedHeaderValue, responseBody string, statusCode int) *httptest.Server { 33 | t.Helper() 34 | 35 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | if expectedHeaderName != "" && expectedHeaderValue != "" { 37 | if r.Header.Get(expectedHeaderName) != expectedHeaderValue { 38 | http.Error(w, `{"error":"Invalid or missing header"}`, http.StatusForbidden) 39 | return 40 | } 41 | } 42 | 43 | w.Header().Set("Content-Type", "application/json") 44 | w.WriteHeader(statusCode) 45 | w.Write([]byte(responseBody)) 46 | })) 47 | 48 | // Automatically shut down the server when the test completes or fails 49 | t.Cleanup(func() { 50 | server.Close() 51 | }) 52 | 53 | return server 54 | } 55 | -------------------------------------------------------------------------------- /pkg/testhelpers/http_client.go: -------------------------------------------------------------------------------- 1 | package testhelpers 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | const internalServerErrorResponse = "internal server error" 11 | 12 | // SuccessStatusHTTPClient is a mock HTTP client implementation which returns HTTP Success (200) status responses. 13 | type SuccessStatusHTTPClient struct{} 14 | 15 | func (c *SuccessStatusHTTPClient) Do(req *http.Request) (*http.Response, error) { 16 | return &http.Response{ 17 | StatusCode: 200, 18 | Body: io.NopCloser(strings.NewReader("mocked Do response")), 19 | }, nil 20 | } 21 | 22 | func (c *SuccessStatusHTTPClient) Get(url string) (*http.Response, error) { 23 | return &http.Response{ 24 | StatusCode: 200, 25 | Body: io.NopCloser(strings.NewReader("mocked Get response")), 26 | }, nil 27 | } 28 | 29 | func (c *SuccessStatusHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, error) { 30 | return &http.Response{ 31 | StatusCode: 200, 32 | Body: io.NopCloser(strings.NewReader("mocked Post response")), 33 | }, nil 34 | } 35 | 36 | // ErrorStatusHTTPClient is a mock HTTP client implementation which returns HTTP Internal Server Error (500) status responses. 37 | type ErrorStatusHTTPClient struct{} 38 | 39 | func (e *ErrorStatusHTTPClient) Do(req *http.Request) (*http.Response, error) { 40 | return &http.Response{ 41 | StatusCode: 500, 42 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 43 | }, nil 44 | } 45 | 46 | func (e *ErrorStatusHTTPClient) Get(url string) (*http.Response, error) { 47 | return &http.Response{ 48 | StatusCode: 500, 49 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 50 | }, nil 51 | } 52 | 53 | func (e *ErrorStatusHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 54 | return &http.Response{ 55 | StatusCode: 500, 56 | Body: io.NopCloser(strings.NewReader(internalServerErrorResponse)), 57 | }, nil 58 | } 59 | 60 | // FailureHTTPClient is a mock HTTP client implementation which simulates a failure to process the given request, returning nil as the response and an error. 61 | type FailureHTTPClient struct{} 62 | 63 | func (f *FailureHTTPClient) Do(req *http.Request) (*http.Response, error) { 64 | return nil, fmt.Errorf("mocked Do error") 65 | } 66 | 67 | func (f *FailureHTTPClient) Get(url string) (*http.Response, error) { 68 | return nil, fmt.Errorf("mocked Get error") 69 | } 70 | 71 | func (f *FailureHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 72 | return nil, fmt.Errorf("mocked Post error") 73 | } 74 | 75 | // InvalidBodyReadCloser implements the [io.ReadCloser] interface and simulates an error when calling Read(). 76 | type InvalidBodyReadCloser struct{} 77 | 78 | func (i *InvalidBodyReadCloser) Read(p []byte) (n int, err error) { 79 | return 0, fmt.Errorf("invalid body") 80 | } 81 | 82 | func (i *InvalidBodyReadCloser) Close() error { 83 | return nil 84 | } 85 | 86 | // InvalidResponseBodyHTTPClient is a mock HTTP client implementation which simulates an invalid response body to trigger an error when trying to read it. 87 | type InvalidResponseBodyHTTPClient struct{} 88 | 89 | func (i *InvalidResponseBodyHTTPClient) Do(req *http.Request) (*http.Response, error) { 90 | return &http.Response{ 91 | StatusCode: 500, 92 | Body: &InvalidBodyReadCloser{}, 93 | }, nil 94 | } 95 | 96 | func (i *InvalidResponseBodyHTTPClient) Get(url string) (*http.Response, error) { 97 | return &http.Response{ 98 | StatusCode: 500, 99 | Body: &InvalidBodyReadCloser{}, 100 | }, nil 101 | } 102 | 103 | func (i *InvalidResponseBodyHTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { 104 | return &http.Response{ 105 | StatusCode: 500, 106 | Body: &InvalidBodyReadCloser{}, 107 | }, nil 108 | } 109 | --------------------------------------------------------------------------------