├── .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 |
3 | dish
4 |
5 |
6 | [](https://pkg.go.dev/go.vxn.dev/dish)
7 | [](https://goreportcard.com/report/go.vxn.dev/dish)
8 | [](https://raw.githack.com/wiki/thevxn/dish/coverage.html)
9 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------