implemented ping and reachibility analyzer functionality

This commit is contained in:
Łukasz Budnik
2023-11-15 20:06:09 +01:00
parent 3ccac4010f
commit 1d44df4ba9
8 changed files with 312 additions and 24 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
debug.test debug.test
cover.out
yosoy yosoy

View File

@@ -2,8 +2,12 @@ FROM golang:1.21-alpine as builder
LABEL maintainer="Łukasz Budnik lukasz.budnik@gmail.com" LABEL maintainer="Łukasz Budnik lukasz.budnik@gmail.com"
# install prerequisites
RUN apk update && apk add git
# build yosoy # build yosoy
ADD . /go/yosoy ADD . /go/yosoy
RUN go env -w GOPROXY=direct
RUN cd /go/yosoy && go build RUN cd /go/yosoy && go build
FROM alpine:3.18 FROM alpine:3.18

View File

@@ -1,15 +1,18 @@
# yosoy ![Go](https://github.com/lukaszbudnik/yosoy/workflows/Go/badge.svg) ![Docker](https://github.com/lukaszbudnik/yosoy/workflows/Docker%20Image%20CI/badge.svg) # yosoy ![Go](https://github.com/lukaszbudnik/yosoy/workflows/Go/badge.svg) ![Docker](https://github.com/lukaszbudnik/yosoy/workflows/Docker%20Image%20CI/badge.svg)
yosoy is a HTTP service for stubbing and prototyping distributed applications. It is a service which will introduce itself to the caller and print some useful information about its environment. "Yo soy" in español means "I am". yosoy is an HTTP service for stubbing and prototyping distributed applications. It is a service that introduces itself to the caller and prints useful information about its runtime environment.
yosoy is extremely useful when creating a distributed application stub and you need to see more meaningful responses than a default nginx welcome page. yosoy is extremely useful when creating a stub for a distributed application, as it provides more meaningful responses than, for example, a default nginx welcome page. Further, yosoy incorporates a built-in reachability analyzer to facilitate troubleshooting connectivity issues in distributed systems. A dedicated reachability analyzer endpoint validates network connectivity between yosoy and remote endpoints.
Typical use cases include: Typical use cases include:
- testing HTTP routing & ingress - Testing HTTP routing and ingress
- testing HTTP load balancing - Testing HTTP load balancing
- testing HTTP caching - Testing HTTP caching
- stubbing and prototyping distributed applications - Executing reachability analysis
- Stubbing and prototyping distributed applications
"Yo soy" means "I am" in Spanish.
## API ## API
@@ -31,19 +34,16 @@ yosoy responds to all requests with a JSON containing the information about:
- Env variables if `YOSOY_SHOW_ENVS` is set to `true`, `yes`, `on`, or `1` - Env variables if `YOSOY_SHOW_ENVS` is set to `true`, `yes`, `on`, or `1`
- Files' contents if `YOSOY_SHOW_FILES` is set to a comma-separated list of (valid) files - Files' contents if `YOSOY_SHOW_FILES` is set to a comma-separated list of (valid) files
Checkout out [Sample JSON response](#sample-json-response) below to see how useful yosoy is when troubleshooting/stubbing/prototyping distributed applications. Check [Sample JSON response](#sample-json-response) to see how you can use yosoy for stubbing/prototyping/troubleshooting distributed applications.
Check [ping/reachibility analyzer](#pingreachibility-analyzer) to see how you can use yosoy for troubleshooting network connectivity.
## Docker image ## Docker image
The docker image is available on docker hub: The docker image is available on docker hub and ghcr.io:
```sh ```sh
docker pull lukasz/yosoy docker pull lukasz/yosoy
```
and ghcr.io:
```sh
docker pull ghcr.io/lukaszbudnik/yosoy docker pull ghcr.io/lukaszbudnik/yosoy
``` ```
@@ -51,7 +51,7 @@ It exposes HTTP service on port 80.
## Kubernetes example ## Kubernetes example
There is a sample Kubernetes deployment file in the `test` folder. It uses both `YOSOY_SHOW_ENVS` and `YOSOY_SHOW_FILES`. The deployment uses Kubernetes Downward API to expose labels and annotations as volume files which are then returned by yosoy. There is a sample Kubernetes deployment file in the `test` folder. It uses both `YOSOY_SHOW_ENVS` and `YOSOY_SHOW_FILES` features. The deployment uses Kubernetes Downward API to expose labels and annotations as volume files which are then returned by yosoy.
Deploy it to minikube and execute curl to the service a couple of times: Deploy it to minikube and execute curl to the service a couple of times:
@@ -131,3 +131,42 @@ A sample yosoy JSON response to a request made from a single page application (S
} }
} }
``` ```
## ping/reachibility analyzer
yosoy includes a simple ping/reachability analyzer. You can use this functionality when prototyping distributed systems to validate whether a given component can reach a specific endpoint. yosoy exposes a dedicated `/_/yosoy/ping` endpoint which accepts the following 3 query parameters:
* `h` - required - hostname of the endpoint
* `p` - required - port of the endpoint
* `n` - optional - network, all valid Go networks are supported (including the most popular ones like `tcp`, `udp`, IPv4, IPV6, etc.). If `n` parameter is not provided, it defaults to `tcp`. Go will throw an error if `n` parameter will be set to unknown network.
For example, to test if yosoy can connect to `google.com` on port `443` using default `tcp` network use the following command:
```bash
curl "$URL/_/yosoy/ping?h=google.com&p=443"
```
To see an unsuccessful response you may use localhost with some random port number:
```bash
curl "$URL/_/yosoy/ping?h=127.0.0.1&p=12345"
```
## Building and testing locally
Here are some commands to get you started.
Run yosoy directly on port 80.
```bash
go test -coverprofile cover.out
go tool cover -html=cover.out
go run server.go
```
Building local Docker container and run it on port 8080:
```bash
docker build -t yosoy-local:latest .
docker run --rm --name yosoy-local -p 8080:80 yosoy-local:latest
```

9
go.mod
View File

@@ -1,9 +1,16 @@
module github.com/lukaszbudnik/yosoy module github.com/lukaszbudnik/yosoy
go 1.16 go 1.21
require ( require (
github.com/gorilla/handlers v1.5.2 github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
) )
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

7
go.sum
View File

@@ -1,4 +1,3 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
@@ -9,15 +8,9 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -26,6 +27,14 @@ type response struct {
Files map[string]string `json:"files,omitempty"` Files map[string]string `json:"files,omitempty"`
} }
type errorResponse struct {
Error string `json:"error"`
}
type successResponse struct {
Message string `json:"message"`
}
var counter = 0 var counter = 0
var hostname = os.Getenv("HOSTNAME") var hostname = os.Getenv("HOSTNAME")
@@ -85,6 +94,55 @@ func handler(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
func ping(w http.ResponseWriter, req *http.Request) {
// get h, p, t parameters from query string
hostname := req.URL.Query().Get("h")
port := req.URL.Query().Get("p")
network := req.URL.Query().Get("n")
// return HTTP BadRequest when hostname is empty
if hostname == "" {
w.WriteHeader(http.StatusBadRequest)
w.Header().Add("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(&errorResponse{"hostname is empty"})
return
}
// return HTTP BadRequest when port is empty
if port == "" {
w.WriteHeader(http.StatusBadRequest)
w.Header().Add("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(&errorResponse{"port is empty"})
return
}
// if network is empty set default to tcp
if network == "" {
network = "tcp"
}
// ping the hostname and port by opening a socket
err := pingHost(hostname, port, network)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Header().Add("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(&errorResponse{err.Error()})
return
}
// return HTTP OK
w.WriteHeader(http.StatusOK)
w.Header().Add("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(&successResponse{"ping succeeded"})
}
func pingHost(hostname, port, network string) error {
// open a socket to hostname and port
conn, err := net.Dial(network, hostname+":"+port)
if err != nil {
return err
}
// close the socket
conn.Close()
return nil
}
func remoteAddrWithoutPort(req *http.Request) string { func remoteAddrWithoutPort(req *http.Request) string {
remoteAddr := req.RemoteAddr remoteAddr := req.RemoteAddr
if index := strings.LastIndex(remoteAddr, ":"); index > 0 { if index := strings.LastIndex(remoteAddr, ":"); index > 0 {
@@ -99,6 +157,7 @@ func main() {
r := mux.NewRouter() r := mux.NewRouter()
r.Handle("/favicon.ico", r.NotFoundHandler) r.Handle("/favicon.ico", r.NotFoundHandler)
r.HandleFunc("/_/yosoy/ping", ping).Methods(http.MethodGet)
r.PathPrefix("/").HandlerFunc(preflight).Methods(http.MethodOptions) r.PathPrefix("/").HandlerFunc(preflight).Methods(http.MethodOptions)
r.PathPrefix("/").HandlerFunc(handler).Methods(http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete, http.MethodConnect, http.MethodHead, http.MethodTrace) r.PathPrefix("/").HandlerFunc(handler).Methods(http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete, http.MethodConnect, http.MethodHead, http.MethodTrace)

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -13,7 +14,7 @@ import (
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
os.Setenv("YOSOY_SHOW_ENVS", "true") os.Setenv("YOSOY_SHOW_ENVS", "true")
os.Setenv("YOSOY_SHOW_FILES", ".gitignore") os.Setenv("YOSOY_SHOW_FILES", ".gitignore,/file/does/not/exist")
req, err := http.NewRequest("GET", "https://example.org/sample/path?one=jeden&two=dwa", nil) req, err := http.NewRequest("GET", "https://example.org/sample/path?one=jeden&two=dwa", nil)
if err != nil { if err != nil {
@@ -50,3 +51,187 @@ func TestHandler(t *testing.T) {
// test cors // test cors
assert.Contains(t, result.Header["Access-Control-Allow-Origin"], "*") assert.Contains(t, result.Header["Access-Control-Allow-Origin"], "*")
} }
// write test for request /_/yosoy/ping without any query parameters, the request should return bad request 400 error and return JSON error about missing hostname parameter
func TestHandlerPingNoParameters(t *testing.T) {
req, err := http.NewRequest("GET", "https://example.org/_/yosoy/ping", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(ping)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
var response errorResponse
json.Unmarshal(buf.Bytes(), &response)
// test response
assert.Equal(t, "hostname is empty", response.Error)
}
// write test for request /_/yosoy/ping with h parameter, the request should return bad request 400 error and return JSON error about port is empty
func TestHandlerPingWithHostname(t *testing.T) {
req, err := http.NewRequest("GET", "https://example.org/_/yosoy/ping?h=example.org", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(ping)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
var response errorResponse
json.Unmarshal(buf.Bytes(), &response)
// test response
assert.Equal(t, "port is empty", response.Error)
}
// write test for request /_/yosoy/ping with h=127.0.0.1 parameter and p=8123 parameter, the request should return bad request 400 error and return JSON error about tcp connection issue
func TestHandlerPingWithHostnameAndPort(t *testing.T) {
req, err := http.NewRequest("GET", "https://example.org/_/yosoy/ping?h=127.0.0.1&p=8123", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(ping)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
var response errorResponse
json.Unmarshal(buf.Bytes(), &response)
// test response
assert.Equal(t, "dial tcp 127.0.0.1:8123: connect: connection refused", response.Error)
}
// write test for request /_/yosoy/ping with h=127.0.0.1 parameter and p=8123 parameter, the request should return 200 ok and return JSON with message ping succeeded
func TestHandlerPingWithHostnameAndPortSuccess(t *testing.T) {
// create tcp process to listen on port 8123
listener, err := net.Listen("tcp", "localhost:8123")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
req, err := http.NewRequest("GET", "https://example.org/_/yosoy/ping?h=127.0.0.1&p=8123", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(ping)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
var response successResponse
json.Unmarshal(buf.Bytes(), &response)
// test response
assert.Equal(t, "ping succeeded", response.Message)
}
// write test for request /_/yosoy/ping?h=127.0.0.1&p=8123&n=qwq, the request should return 500 internal server error and return JSON with error "dial qwq: unknown network qwq"
func TestHandlerPingWithHostnameAndPortAndNetwork(t *testing.T) {
// create tcp process to listen on port 8123
listener, err := net.Listen("tcp", "localhost:8123")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
req, err := http.NewRequest("GET", "https://example.org/_/yosoy/ping?h=127.0.0.1&p=8123&n=qwq", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(ping)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
var response errorResponse
json.Unmarshal(buf.Bytes(), &response)
// test response
assert.Equal(t, "dial qwq: unknown network qwq", response.Error)
}
// write test for preflight HTTP Options request, verify that all headers are set
func TestHandlerPreflight(t *testing.T) {
req, err := http.NewRequest("OPTIONS", "https://example.org/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Accept", "*/*")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(preflight)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
result := rr.Result()
buf := new(bytes.Buffer)
buf.ReadFrom(result.Body)
// test response
assert.Equal(t, "*", result.Header.Get("Access-Control-Allow-Origin"))
assert.Equal(t, "*", result.Header.Get("Access-Control-Allow-Methods"))
assert.Equal(t, "*", result.Header.Get("Access-Control-Allow-Headers"))
assert.Equal(t, "true", result.Header.Get("Access-Control-Allow-Credentials"))
assert.Equal(t, "600", result.Header.Get("Access-Control-Max-Age"))
assert.Equal(t, "*", result.Header.Get("Access-Control-Expose-Headers"))
}

View File

@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: yosoy - name: yosoy
image: lukasz/yosoy:2.0.3 image: lukasz/yosoy:edge
env: env:
- name: YOSOY_SHOW_ENVS - name: YOSOY_SHOW_ENVS
value: "true" value: "true"