diff --git a/README.md b/README.md index ae5d7fa..31324d9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,16 @@ yosoy responds to all requests with a JSON containing the information about: Check [Sample JSON response](#sample-json-response) to see how you can use yosoy for stubbing/prototyping/troubleshooting distributed applications. -Check [ping/reachability analyzer](#pingreachability-analyzer) to see how you can use yosoy for troubleshooting network connectivity. +## ping/reachability 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 4 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`. If `n` parameter is set to unknown network, an error will be returned. +* `t` - optional - timeout in seconds. If `t` parameter is not provided, it defaults to `10`. If `t` contains invalid integer literal, an error will be returned. + +Check [Sample ping/reachability analyzer responses](#sample-pingreachability-analyzer-responses) to see how you can use yosoy for troubleshooting network connectivity. ## Docker image @@ -132,24 +141,40 @@ A sample yosoy JSON response to a request made from a single page application (S } ``` -## ping/reachability 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. +## Sample ping/reachability analyzer responses -For example, to test if yosoy can connect to `google.com` on port `443` using default `tcp` network use the following command: +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" +curl -v "http://localhost/_/yosoy/ping?h=google.com&p=443" +> GET /_/yosoy/ping?h=google.com&p=443 HTTP/1.1 +> Host: localhost +> User-Agent: curl/7.86.0 +> Accept: */* +> +< HTTP/1.1 200 OK +< Date: Fri, 17 Nov 2023 05:54:36 GMT +< Content-Length: 29 +< Content-Type: text/plain; charset=utf-8 +< +{"message":"ping succeeded"} ``` 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" +curl -v "http://localhost/_/yosoy/ping?h=127.0.0.1&p=12345" +> GET /_/yosoy/ping?h=127.0.0.1&p=12345 HTTP/1.1 +> Host: localhost +> User-Agent: curl/7.86.0 +> Accept: */* +> +< HTTP/1.1 500 Internal Server Error +< Date: Fri, 17 Nov 2023 05:53:48 GMT +< Content-Length: 66 +< Content-Type: text/plain; charset=utf-8 +< +{"error":"dial tcp 127.0.0.1:12345: connect: connection refused"} ``` ## Building and testing locally diff --git a/server.go b/server.go index af22e1e..f47d00f 100644 --- a/server.go +++ b/server.go @@ -6,12 +6,17 @@ import ( "net" "net/http" "os" + "strconv" "strings" + "time" "github.com/gorilla/handlers" "github.com/gorilla/mux" ) +const PING_DEFAULT_TIMEOUT = 10 +const PING_DEFAULT_NETWORK = "tcp" + type response struct { Host string `json:"host"` Proto string `json:"proto"` @@ -95,10 +100,12 @@ func handler(w http.ResponseWriter, req *http.Request) { } func ping(w http.ResponseWriter, req *http.Request) { - // get h, p, t parameters from query string + // get h, p, n, t parameters from query string hostname := req.URL.Query().Get("h") port := req.URL.Query().Get("p") network := req.URL.Query().Get("n") + timeoutString := req.URL.Query().Get("t") + var timeout int64 = PING_DEFAULT_TIMEOUT // return HTTP BadRequest when hostname is empty if hostname == "" { @@ -114,12 +121,25 @@ func ping(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(&errorResponse{"port is empty"}) return } + // check if timeoutString is a valid int64, return HTTP BadRequest when invalid, otherwise set timeout value + if timeoutString != "" { + timeoutInt, err := strconv.ParseInt(timeoutString, 10, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Header().Add("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(&errorResponse{"timeout is invalid"}) + return + } + timeout = timeoutInt + } + // if network is empty set default to tcp if network == "" { - network = "tcp" + network = PING_DEFAULT_NETWORK } - // ping the hostname and port by opening a socket - err := pingHost(hostname, port, network) + + // ping the hostname and port + err := pingHost(hostname, port, network, timeout) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Header().Add("Content-Type", "application/json; charset=utf-8") @@ -132,9 +152,12 @@ func ping(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(&successResponse{"ping succeeded"}) } -func pingHost(hostname, port, network string) error { +func pingHost(hostname, port, network string, timeout int64) error { + // create timeoutDuration variable of Duration type using timeout as the value in seconds + timeoutDuration := time.Duration(timeout) * time.Second + // open a socket to hostname and port - conn, err := net.Dial(network, hostname+":"+port) + conn, err := net.DialTimeout(network, hostname+":"+port, timeoutDuration) if err != nil { return err } diff --git a/server_test.go b/server_test.go index dc58de5..11fbade 100644 --- a/server_test.go +++ b/server_test.go @@ -235,3 +235,71 @@ func TestHandlerPreflight(t *testing.T) { assert.Equal(t, "600", result.Header.Get("Access-Control-Max-Age")) assert.Equal(t, "*", result.Header.Get("Access-Control-Expose-Headers")) } + +// write test for request /_/yosoy/ping?h=127.0.0.1&p=8123&n=tcp&t=5, the request should return 200 ok and return JSON with message "ping succeeded" +func TestHandlerPingWithHostnameAndPortAndNetworkAndTimeout(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=tcp&t=5", 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=tcp&t=invalid, the request should return 400 bad request and return JSON with error "timeout is invalid" +func TestHandlerPingWithHostnameAndPortAndNetworkAndTimeoutInvalid(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=tcp&t=invalid", 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, "timeout is invalid", response.Error) +}