diff --git a/CHANGELOG.md b/CHANGELOG.md index 2854e24..1dad81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.1.0 - 2018-01-30 + +### Added + +- TLS support + + `--tls` verify server with CA certificates installed on this system + `--tls-insecure` do NOT verify server (accept any certificate) + `--tls-cafile value` verify server with CA certificate stored in specified file + `--tls-capath value` verify server with CA certificates located under specified path +- Windows build + +### Changed + +- `--timeout` option now affects both dialing to server and RPC call (before that dialing had hard-coded 1s timeout) +- more descriptive messages are printed out in case of failure + ## 1.0.0 - 2017-12-13 ### Added diff --git a/Makefile b/Makefile index bc13609..00c5da8 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ bin: deps ${BINARY} release: release/gprobe-linux-amd64-${VERSION}.tar.gz release: release/gprobe-linux-386-${VERSION}.tar.gz release: release/gprobe-darwin-amd64-${VERSION}.tar.gz +release: release/gprobe-windows-amd64-${VERSION}.tar.gz .PHONY: release-dir release-dir: diff --git a/README.md b/README.md index 4dec2a3..0594832 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,16 @@ Get help gprobe -h ``` -## Building +## Building from source Valid _go_ environment is required to build `gprobe` (`go` is in `PATH`, `GOPATH` is set, etc.). +Clone code into valid `GOPATH` location + +```bash +git clone git@github.com:ncbi/gprobe.git $GOPATH/src/github.com/ncbi/gprobe +``` + Build distributable tarballs for all OSes ```bash @@ -56,7 +62,7 @@ This project follows git-flow branching model. All development is done off of th To contribute: -1. Create a feature branch from the latest `develop`, commit your work there +1. Fork or create a feature branch from the latest `develop`, commit your work there ```bash git checkout develop git pull diff --git a/acctest/key.pem b/acctest/key.pem new file mode 100644 index 0000000..b2dbb0b --- /dev/null +++ b/acctest/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDP1dCRnLuw2Jo2 +1GoCOdunHC3uBjUUTI0CGog+9RP2gsNgGdNHM77Htg6Qjrs51PngkYpC5GFpfkbx +6CaFYpsPOWyJcdUnDnieDhMTHMu7PwHKWf1hgSJxoiNbQKjNH+sJTW+au3pshuoX +JMl0uibis+pyRtfD3MK5wanASlmEC2ldyiEXW8JZgARY94/R7/c7AIMsH1YJq4PR +CJ8pQmaV7xSrlT+/r+bB9JtBNTFkehaC/D1hf7IihcCwlanHgGiVJdPpprX+CASM +8EVrXNmm55Xq+edSc4yHeb2l8AH8OXrsDfNNwuVv1f70PTmae7WgKdXfLR1avDUf +oDxCPoAoVgncl5e6Wm0zGFVzuJtHl+tgyzFqwduXjnm7MGY/0IODFaS37DKxxaFC +Ff5y1TuaIeOZZBzt1+2RDFw5vOajB/SL76VgM06qi+TW9hCES4jAS6H56KUP0Rpl +MxRCe7mRU+2w8+RuOhJKtjT8YPPWCP3NfvfS/iCsk9OIOuJQycdonXs7pfruvftA +/Y1j8gRhrExauIeIYGIaq4KRolKQDaAUQd2EFg95UVVpTpEZL9bYM/YBDccH+W8B +hgo6N2n0eUEFqiclWFtvAN1vAAMTe1/CjYh41HkqIlxA51rJ+7tekRd8xr2BrKKc +9Vu1POmTGdrjr30OET8+6jzdL5MwewIDAQABAoICAQCUMKeDe9bUVM04tSJVLf3x +XIVfR2vHaoHMczCce1DdnwVB24grJ7krWyNtbWgP50y4E+4ang7bEl/xko8M4m8f +XtmF8vWB4K6eO/jb0tdtTpKvPpUNVe9CSNKe+S6i+9QxkNY35N94zIXTNLa0FRsu +4AwVqW+lRx5NJsorwperMBvT9RC9P/8Go+H1sacJkOmeV1IwPrOxN2tIu7YIzECr +PYpmgYev3PNTbl7ZEt2CAA9XHBWEFHHmbaoj/sLM7kEjv5Im8minlf3wpE1LLSw/ +9raNkdyfjKYx3tsbm1M/DZkZASVvV70Sjeo5KgKNpRGu/sVxWRCqJrJWN4Ff1oK3 +dXi1zdoC56mNsqDLaHftFBy6T85UlNJhvXrNxr9JI8SaflCBgLrV1yT+5SkYjc4t +xqAAl18TFk3eZL7Wf4rK6Z9c1NlUW/H44OHgd3sUQMdmbaKhT9eyxlohdMTl7ly/ +l2fczZGoaX4dwNvrw6MQGFC/J4e7x2FARTm6UAbVeJ9hqwx3qY02MgCVO8ZhCdPk +aJ7rxFUIdhYHY84sGN27zxjx4ja7b/NdEB8glVXewPpWuZbngLMgWvlLGsLdQfoS +DZmXT4B6AwA/6DW2tMKMYAv+yud5VmeiXQ9vRkuwT4lHLY9CcoFbl63PIkeeKDHe ++9r7mkbUtmOgiw8qRcDJeQKCAQEA/sU7oKemdKl9HXnheNh+ql3C9/t1OaHkHlKv +g964r2rjIpYhiNS59kmXizA1KEJAmk2BZfV7gSuarbGU8v4WAcQQUEJ/c0aqf02K +7XfH/dIEf2C3IngTYybN08Eq8uQNyrNsXcIYCEjY9vDedouP0J5kZN+e66Aqxj8/ +dp3nvIt8XL/0eGwq5xa6tJwhX3EzGim34Hv4Ifb0TGeko52fah1oCAfqmYRMJlH6 +zJhBhCH7IszlqItx3t6+KEg4dUIx1Yi4nh7XI9zQg+fzssh9kDeBsbpCRnUPat9m +FgCLsZRaQYTW50xt3DTEYS8sGzjfmrhXXHEDek/oZwvZAr1k3wKCAQEA0NaX+hmP +vQvqBhBVYfSH/KAU9JCyF5yWoMJlJOOyb4ryUU1Fs3cAS8vMCUkRcZK/Q7icpm4r +k+3/2BPZeOzWhSOGJTvJ1oSsS9cmuq4MSJDmDXP4G0FS7uAKOj1HUjXwd7dWrHvE +IlW9PrvVNXOLedzA8k45ZCQRBawsLoaBJg64EZsLc87PEECxGiSBiN4aabr191xc +J5IqF51P6D2mUdhnf6GZvBmBTtzs9ocmGEV5Efv/VO/CcgL5kQOOR7jpB0lIIHwF +2WjLLalEQ1r6iBQSYswXdcTQT5MYX+DxXFv//qePFuaM1O4c3G4ODhJrGHsez1EN +g4uFnKmBoBmr5QKCAQAY3S7gkvwPzqrDQa3bmWVjQxtQEF50bXRR8Ufn2sizdf8M +1RIYxIoRm0UK9H17nFups365cKfJB3RlFzuuK1YCfhwJeTPvECp7mhnA6zu9bc26 +kLnOx2E9AAB+dg+2/MLL0Y7154do55MlJoTPlPdIKO0rWxerb0o9ZtbOwMJpCEPu +2V0Gk6fsPa+jCMnJAsc1+nRTmEWzKuLUwhizTyLLvGr2va8LpHm6E64iYYmjV52m +29BeDp3iXmK4k7PO3dL3QAykgeYFPfuro+uIu0Bl3sTtj3wAXFRQ3dScuRjpD81v +L4O5tx/RqeSwh2YKkhZghzUfdHgea8YGqIVZWxqJAoIBAQDGyn8AYzSgD6dE/mdI +RyzrHLbV1qawMy5u+Jyu8M/5vZnMKnIe0zhE7knazOL96WKHZEQ5aMWymurfFIX+ +xfOt6JLY/oCy4rffuX30VZj7unJCfBHAX/5BxKH3rj0l1JKCYtLufSHGTTdHcCUU +LFioN6qy/CNFX8+URsAHyaFGSNyOZbgRFNul7O6oo/dqAYHDA2T/gbt3L3tB300h +FQ4s+oIKzBk7JEwidcpbIWrxz6/fnrD+ePvu60YE9A2L2Eh51xgBVA19VnORk36X +XxL8VZ7qzLvILwDbvnmFSup1sF2OWpGqiuukBMUUTu6yFnY7Z3d8gPsMLNOSvQfX +DpjBAoIBAAGrx9X6JFCz0rE3fSyZVr9+tYVcz0kt+B9a3bN08/szO7c6xBbGeaRS +5wrTVomMeiQWd5q7xttAB+XhqqnJqoPt+tDJj5CA239lNHFCOv+ayk0f5Luequ2B +hI8ylsa17oPKrPj+wR1WHfMQg6F+hFay29Kz9usQJY0ciXb1pE31Qm28ldnAiWX0 +hO98do3+E5mS/XcTuerXTRQoctFaXeYVx5tV6XHYth0kULAdEX5/z6ZvGXFS7K/3 +V608CusCn3MsRYjFoRTHOL2LswhM4yGbJBcMg+tVCpS6K4nwBCbf/+ro/1oKrm8f +LiCYg6qO9tI/KXTUZjr6VMtlZ13CAEY= +-----END PRIVATE KEY----- diff --git a/acctest/main_test.go b/acctest/main_test.go index 91b07a9..f401c0d 100644 --- a/acctest/main_test.go +++ b/acctest/main_test.go @@ -22,64 +22,52 @@ package acctest import ( "bytes" + "flag" "fmt" "github.com/stretchr/testify/assert" - "google.golang.org/grpc" - "google.golang.org/grpc/health" - hv1 "google.golang.org/grpc/health/grpc_health_v1" "io" "log" - "net" "os" "os/exec" "syscall" "testing" - "flag" + + hv1 "google.golang.org/grpc/health/grpc_health_v1" ) var ( - port int - listenAddr string - bin string + port int + caFile string + caPath string + key string + bin string + stubSrvAddr string ) -func startServer() net.Listener { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - grpcServer := grpc.NewServer() - server := health.NewServer() - hv1.RegisterHealthServer(grpcServer, server) - - server.SetServingStatus("foo", hv1.HealthCheckResponse_SERVING) - server.SetServingStatus("bar", hv1.HealthCheckResponse_NOT_SERVING) - go grpcServer.Serve(listener) - return listener -} - func init() { flag.IntVar(&port, "stub-port", 54321, "port for the stub server") - flag.StringVar(&bin, "gprobe", "", "path to the gprobe binary") + flag.StringVar(&caFile, "stub-cafile", "x509/certificate.pem", "path to the x509 certificate file") + flag.StringVar(&caPath, "stub-capath", "x509/", "path to the x509 certificates dir") + flag.StringVar(&key, "stub-key", "key.pem", "path to the stub server private key") + flag.StringVar(&bin, "gprobe", "../gprobe", "path to the gprobe binary") } func TestMain(m *testing.M) { flag.Parse() - listenAddr = fmt.Sprintf("%s:%d", "localhost", port) - - lis := startServer() - result := 0 - defer func() { - lis.Close() - os.Exit(result) - }() - - result = m.Run() + stubSrvAddr = fmt.Sprintf("%s:%d", "localhost", port) + os.Exit(m.Run()) } func TestShouldReturnServingForRunningServer(t *testing.T) { - stdout, stderr, exitcode := runBin(t, listenAddr) + // given + srv, _, err := StartInsecureServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr) assert.Equal(t, 0, exitcode) assert.Equal(t, "SERVING\n", stdout) @@ -87,43 +75,173 @@ func TestShouldReturnServingForRunningServer(t *testing.T) { } func TestShouldFailIfServerIsNotListening(t *testing.T) { - stdout, stderr, exitcode := runBin(t, "nosuchhost:1234") + // given no server + + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr) + + // then + assert.Equal(t, 127, exitcode) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "application isn't listening") +} + +func TestShouldFailIfServerDoesNotImplementHealthCheckProtocol(t *testing.T) { + // given + srv, err := StartEmptyServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr) + + // then assert.Equal(t, 127, exitcode) assert.Empty(t, stdout) - assert.Contains(t, stderr, "error", "should print status to STDOUT") + assert.Equal(t, stderr, "rpc error: server doesn't implement gRPC health-checking protocol\n") } func TestShouldReturnServingForHealthyService(t *testing.T) { - stdout, stderr, exitcode := runBin(t, listenAddr, "foo") + // given + srv, svc, err := StartInsecureServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + svc.SetServingStatus("foo", hv1.HealthCheckResponse_SERVING) + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr, "foo") + + // then assert.Equal(t, 0, exitcode) assert.Equal(t, "SERVING\n", stdout) assert.Empty(t, stderr) } func TestShouldReturnNotServingForUnhealthyService(t *testing.T) { - stdout, stderr, exitcode := runBin(t, listenAddr, "bar") + // given + srv, svc, err := StartInsecureServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + svc.SetServingStatus("foo", hv1.HealthCheckResponse_NOT_SERVING) + + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr, "foo") + // then assert.Equal(t, 2, exitcode) assert.Equal(t, "NOT_SERVING\n", stdout) assert.Contains(t, stderr, "health-check failed") } func TestShouldNotFailForUnhealthyServiceIfNoFailIsSet(t *testing.T) { - stdout, stderr, exitcode := runBin(t, "--no-fail", listenAddr, "bar") + // given + srv, svc, err := StartInsecureServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + svc.SetServingStatus("foo", hv1.HealthCheckResponse_NOT_SERVING) + + // when + stdout, stderr, exitcode := runBin(t, "--no-fail", stubSrvAddr, "foo") + // then assert.Equal(t, 0, exitcode) assert.Equal(t, "NOT_SERVING\n", stdout) assert.Empty(t, stderr) } func TestShouldFailIfServiceHealthCheckIsNotRegistered(t *testing.T) { - stdout, stderr, exitcode := runBin(t, listenAddr, "non_registered_service") + // given + srv, _, err := StartInsecureServer(port) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + // when + stdout, stderr, exitcode := runBin(t, stubSrvAddr, "my.service.Foo") + + // then assert.Equal(t, 127, exitcode) assert.Empty(t, stdout) - assert.Contains(t, stderr, "NotFound") + assert.Equal(t, stderr, "rpc error: unknown service my.service.Foo\n") +} + +// TLS tests + +func TestShouldFailOnTlsVerificationWithSelfSignedCert(t *testing.T) { + // given + srv, _, err := StartServer(port, caFile, key) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + + // when + stdout, stderr, exitcode := runBin(t, "--tls", stubSrvAddr) + + // then + assert.Equal(t, 127, exitcode) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "TLS handshake failed") +} + +func TestShouldBeAbleToSkipTlsVerification(t *testing.T) { + // given + srv, _, err := StartServer(port, caFile, key) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + + // when + stdout, stderr, exitcode := runBin(t, "--tls-insecure", stubSrvAddr) + + // then + assert.Equal(t, 0, exitcode) + assert.Equal(t, "SERVING\n", stdout) + assert.Empty(t, stderr) +} + +func TestShouldBeAbleToSetCustomCAFile(t *testing.T) { + // given + srv, _, err := StartServer(port, caFile, key) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + + // when + stdout, stderr, exitcode := runBin(t, "--tls-cafile", caFile, stubSrvAddr) + + // then + assert.Equal(t, 0, exitcode) + assert.Equal(t, "SERVING\n", stdout) + assert.Empty(t, stderr) +} + +func TestShouldBeAbleToSetCustomCAPath(t *testing.T) { + // given + srv, _, err := StartServer(port, caFile, key) + if err != nil { + log.Fatalf("can't start stub server: %v", err) + } + defer srv.GracefulStop() + + // when + stdout, stderr, exitcode := runBin(t, "--tls-capath", caPath, stubSrvAddr) + + // then + assert.Equal(t, 0, exitcode) + assert.Equal(t, "SERVING\n", stdout) + assert.Empty(t, stderr) } func runBin(t *testing.T, args ...string) (stdout string, stderr string, exitcode int) { @@ -166,5 +284,3 @@ func waitForExitCode(t *testing.T, cmd *exec.Cmd) (exitcode int) { } return } - - diff --git a/acctest/stubserver.go b/acctest/stubserver.go new file mode 100644 index 0000000..8e768ab --- /dev/null +++ b/acctest/stubserver.go @@ -0,0 +1,71 @@ +// PUBLIC DOMAIN NOTICE +// National Center for Biotechnology Information +// +// This software/database is a "United States Government Work" under the +// terms of the United States Copyright Act. It was written as part of +// the author's official duties as a United States Government employee and +// thus cannot be copyrighted. This software/database is freely available +// to the public for use. The National Library of Medicine and the U.S. +// Government have not placed any restriction on its use or reproduction. +// +// Although all reasonable efforts have been taken to ensure the accuracy +// and reliability of the software and data, the NLM and the U.S. +// Government do not and cannot warrant the performance or results that +// may be obtained by using this software or data. The NLM and the U.S. +// Government disclaim all warranties, express or implied, including +// warranties of performance, merchantability or fitness for any particular +// purpose. +// +// Please cite the author in any work or product based on this material. + +package acctest + +import ( + "fmt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + hv1 "google.golang.org/grpc/health/grpc_health_v1" + "net" +) + +// StartServer starts new gRPC application with simple health service. +// It is callers responsibility to Stop the server +func StartServer(port int, certFile string, keyFile string) (*grpc.Server, *health.Server, error) { + transportCredentials, err := credentials.NewServerTLSFromFile(certFile, keyFile) + if err != nil { + return nil, nil, err + } + return doStart(port, grpc.Creds(transportCredentials)) +} + +// StartInsecureServer starts new gRPC application with simple health service. +// It is callers responsibility to Stop the server +func StartInsecureServer(port int) (*grpc.Server, *health.Server, error) { + return doStart(port) +} + +func doStart(port int, options ...grpc.ServerOption) (server *grpc.Server, service *health.Server, err error) { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return + } + server = grpc.NewServer(options...) + service = health.NewServer() + hv1.RegisterHealthServer(server, service) + + go server.Serve(listener) + return server, service, nil +} + +// StartEmptyServer starts gRPC server application with no services +func StartEmptyServer(port int) (server *grpc.Server, err error) { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return + } + server = grpc.NewServer() + + go server.Serve(listener) + return server, nil +} diff --git a/acctest/x509/certificate.pem b/acctest/x509/certificate.pem new file mode 100644 index 0000000..3c0f8f8 --- /dev/null +++ b/acctest/x509/certificate.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE/jCCAuagAwIBAgIJAOUm28+Yjo8kMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0xNzEyMjgwNDQ2MTZaFw00NTA1MTQwNDQ2MTZaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAM/V0JGcu7DYmjbUagI526ccLe4GNRRMjQIaiD71E/aCw2AZ00czvse2DpCO +uznU+eCRikLkYWl+RvHoJoVimw85bIlx1ScOeJ4OExMcy7s/AcpZ/WGBInGiI1tA +qM0f6wlNb5q7emyG6hckyXS6JuKz6nJG18PcwrnBqcBKWYQLaV3KIRdbwlmABFj3 +j9Hv9zsAgywfVgmrg9EInylCZpXvFKuVP7+v5sH0m0E1MWR6FoL8PWF/siKFwLCV +qceAaJUl0+mmtf4IBIzwRWtc2abnler551JzjId5vaXwAfw5euwN803C5W/V/vQ9 +OZp7taAp1d8tHVq8NR+gPEI+gChWCdyXl7pabTMYVXO4m0eX62DLMWrB25eOebsw +Zj/Qg4MVpLfsMrHFoUIV/nLVO5oh45lkHO3X7ZEMXDm85qMH9IvvpWAzTqqL5Nb2 +EIRLiMBLofnopQ/RGmUzFEJ7uZFT7bDz5G46Ekq2NPxg89YI/c1+99L+IKyT04g6 +4lDJx2idezul+u69+0D9jWPyBGGsTFq4h4hgYhqrgpGiUpANoBRB3YQWD3lRVWlO +kRkv1tgz9gENxwf5bwGGCjo3afR5QQWqJyVYW28A3W8AAxN7X8KNiHjUeSoiXEDn +Wsn7u16RF3zGvYGsopz1W7U86ZMZ2uOvfQ4RPz7qPN0vkzB7AgMBAAGjUzBRMB0G +A1UdDgQWBBT+pbYheKFdRl8CJLLgUgi5uQlZmTAfBgNVHSMEGDAWgBT+pbYheKFd +Rl8CJLLgUgi5uQlZmTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IC +AQBDa1v9VawozMfWUWB1iBrpnJx5jb4Xbjvlwag7pgO8e2PFq2rBPxxCEHc2usQS +tcF6EuHfpDRlhu431Xuw+6HhgakoqPlgaxgXBn6zbteDVSxOQfij377F09cYAww5 +jnKCaItTSFxWW0kSlPI6m1QyWDFHSshgpvRRr58yY8OJbn9oAEKp39x9gprgVWuw +vcjgUCKCCouXburYmW82Yo0A5c5jnyDbFeM3iC2N85KoencQAYt/tKT4N65+Ublf +1aAeRGUrByfUfNEly00Clg3It7yamGRGLFHdd0yNBohcm7HfEsiACkyd0yaHaAxY +TbmujnNRNkiHYeAjcMqOKUnK2ZymKmzC0ALpoAQaHpws7qKqXJ7K6Nh5jixNRcYT +I0nkQCO8GbMl6/TkuevGRqxEQnmCHTek7y0T493HeZDGMsgWhbH8jvZvCAAWXvBf +7iPyU9d6P0Q0u4Hcn7wP/IBKCq06L448yP18SPwoNYAlKbcCZ067O+4Zdf5yAgU5 +PiWk9rbbMbWB7nQ8yZ86Z8wuZ76dftnJ8ASYJJk38SUkrQC3AoKhj6+GKcXLwZEV +OVq5m9NCM695wo+5d0rnusz2EPAQpvLE4bFveH/2wEk8yduYCvAQC3PwD47ypsy0 +Vz/Aa7mPgEj/yAp1Z3eh0DrAiF3fux3OVlha5BiTntYd/w== +-----END CERTIFICATE----- diff --git a/main.go b/main.go index 06ab54e..e3099c8 100644 --- a/main.go +++ b/main.go @@ -21,14 +21,21 @@ package main import ( + "context" + "crypto/tls" "fmt" - "github.com/ncbi/gprobe/probe" + "github.com/hashicorp/go-rootcerts" "github.com/urfave/cli" - "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + hv1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" "os" "time" ) +// version variable is set during compilation using ldflags var version string const ( @@ -40,20 +47,31 @@ const ( ExitCodeUnexpected = 127 ) -// appInput holds all parsed CLI flags and arguments -type appInput struct { +// appFlags holds flags passed to application +type appFlags struct { + timeout time.Duration + noFail bool + tls bool + tlsInsecure bool + tlsCAFile string + tlsCAPath string +} + +// appConfig holds processed application config +type appConfig struct { timeout time.Duration noFail bool serverAddress string serviceName string + creds credentials.TransportCredentials } -// mainFn holds main application business logic -type mainFn func(appInput *appInput) *cli.ExitError +// mainFn is main application business logic +type mainFn func(config *appConfig) *cli.ExitError func createApp(mainFn mainFn) *cli.App { app := cli.NewApp() - appInput := &appInput{} + flags := &appFlags{} app.Name = "gprobe" app.Usage = "universal gRPC health-checker. See https://github.com/grpc/grpc/blob/master/doc/health-checking.md" @@ -71,56 +89,202 @@ func createApp(mainFn mainFn) *cli.App { cli.DurationFlag{ Name: "timeout, t", Usage: "Operation timeout", - Destination: &appInput.timeout, + Destination: &flags.timeout, Value: 1 * time.Second, }, cli.BoolFlag{ Name: "no-fail, n", Usage: "Do not fail if service status is other than SERVING. Note: this has no effect on server check", - Destination: &appInput.noFail, + Destination: &flags.noFail, + }, + cli.BoolFlag{ + Name: "tls", + Usage: "Use TLS, verify server with CA certificates installed on this system", + Destination: &flags.tls, + }, + cli.BoolFlag{ + Name: "tls-insecure", + Usage: "Use TLS, do NOT verify server (accept any certificate)", + Destination: &flags.tlsInsecure, + }, + cli.StringFlag{ + Name: "tls-cafile", + EnvVar: "GPROBE_CAFILE", + Usage: "Use TLS, verify server with CA certificate stored in specified file", + Destination: &flags.tlsCAFile, + }, + cli.StringFlag{ + Name: "tls-capath", + EnvVar: "GPROBE_CAPATH", + Usage: "Use TLS, verify server with CA certificates located under specified path", + Destination: &flags.tlsCAPath, }, } app.Action = func(c *cli.Context) error { - switch c.NArg() { - case 2: - appInput.serviceName = c.Args().Get(1) - appInput.serverAddress = c.Args().Get(0) - break - case 1: - appInput.serverAddress = c.Args().Get(0) - break - default: - return c.App.OnUsageError(c, fmt.Errorf("exactly 1 to 2 arguments are required"), false) + appConfig, err := createConfig(flags, c.Args()) + if err != nil { + return c.App.OnUsageError(c, err, false) } // Pass all input to mainFn - return mainFn(appInput) + return mainFn(appConfig) } - return app } +func createConfig(flags *appFlags, args cli.Args) (config *appConfig, err error) { + config = &appConfig{} + switch len(args) { + case 2: + config.serviceName = args.Get(1) + config.serverAddress = args.Get(0) + break + case 1: + config.serverAddress = args.Get(0) + break + default: + return nil, fmt.Errorf("exactly 1 to 2 arguments are required") + } + + creds, err := parseCredentials(flags) + if err != nil { + return nil, fmt.Errorf("can't parse TLS configuration: %s", err.Error()) + } + + config.creds = creds + config.timeout = flags.timeout + config.noFail = flags.noFail + return +} + +func parseCredentials(flags *appFlags) (credentials.TransportCredentials, error) { + // rootcerts library accepts both CAFile and CAPath, however handles only one of two, the other is ignored + // to avoid ambiguity in behavior we do additional flags validation and explicitly allow only one flag set + switch countTLSFlags(flags) { + case 0: + // no tls + return nil, nil + case 1: + tlsConfig, err := createTLSConfig(flags.tlsCAFile, flags.tlsCAPath, flags.tlsInsecure) + if err != nil { + return nil, err + } + creds := credentials.NewTLS(tlsConfig) + return creds, nil + default: + err := fmt.Errorf("at most one of --tls, --tls-insecure, --tls-cafile and --tls-capath is allowed") + return nil, err + } +} + +func countTLSFlags(flags *appFlags) int { + tlsFlagsSet := 0 + if flags.tls { + tlsFlagsSet++ + } + if flags.tlsInsecure { + tlsFlagsSet++ + } + if len(flags.tlsCAFile) > 0 { + tlsFlagsSet++ + } + if len(flags.tlsCAPath) > 0 { + tlsFlagsSet++ + } + return tlsFlagsSet +} + +func createTLSConfig(caFile string, caPath string, insecure bool) (tlsConfig *tls.Config, err error) { + tlsConfig = &tls.Config{} + + if insecure { + tlsConfig.InsecureSkipVerify = true + return + } + + certs := &rootcerts.Config{ + CAFile: caFile, + CAPath: caPath, + } + err = rootcerts.ConfigureTLS(tlsConfig, certs) + if err != nil { + tlsConfig = nil + return + } + + return +} + func main() { createApp(appMain).Run(os.Args) } -func appMain(appInput *appInput) *cli.ExitError { - probe.OpTimeout = appInput.timeout - err := probe.Connect(appInput.serverAddress) +func appMain(config *appConfig) *cli.ExitError { + ctx, cancel := context.WithTimeout(context.Background(), config.timeout) + defer cancel() + + connection, err := connect(ctx, config.serverAddress, config.creds) if err != nil { - return cli.NewExitError(err.Error(), ExitCodeUnexpected) + // actually should never happen because we use non-blocking dialer and failFast RPC (defaults) + return cli.NewExitError(fmt.Sprintf("can't connect to application: %s", err.Error()), ExitCodeUnexpected) } - defer probe.Disconnect() + defer connection.Close() - status, err := probe.CheckService(appInput.serviceName) + status, err := check(ctx, connection, config.serviceName) if err != nil { return cli.NewExitError(err.Error(), ExitCodeUnexpected) } fmt.Fprintln(os.Stdout, status.String()) - if !(appInput.noFail || status == grpc_health_v1.HealthCheckResponse_SERVING) { + if !(config.noFail || status == hv1.HealthCheckResponse_SERVING) { return cli.NewExitError("health-check failed", ExitCodeHealthCheckNegative) } // for some reason returning nil here causes err == nil to be false in urfave/cli/errors.go:79 return cli.NewExitError("", 0) } + +func connect(ctx context.Context, serverAddress string, creds credentials.TransportCredentials) (connection *grpc.ClientConn, err error) { + var dialOption grpc.DialOption + if creds == nil { + dialOption = grpc.WithInsecure() + } else { + dialOption = grpc.WithTransportCredentials(creds) + } + connection, err = grpc.DialContext(ctx, serverAddress, dialOption) + return +} + +func check(ctx context.Context, connection *grpc.ClientConn, service string) (status hv1.HealthCheckResponse_ServingStatus, err error) { + client := hv1.NewHealthClient(connection) + response, err := client.Check(ctx, &hv1.HealthCheckRequest{ + Service: service, + }) + + if response != nil { + status = response.Status + } + + err = toHumanReadable(err, service) + + return +} + +func toHumanReadable(err error, service string) error { + code := status.Code(err) + switch code { + case codes.OK: + return err // err is nil + case codes.Unavailable: + return fmt.Errorf("connection refused: application isn't listening or TLS handshake failed") + case codes.Unimplemented: + return fmt.Errorf("rpc error: server doesn't implement gRPC health-checking protocol") + case codes.NotFound: + return fmt.Errorf("rpc error: unknown service %s", service) + default: + if s, isRPCError := status.FromError(err); isRPCError { + // display only message from generic rpc errors, hide code + return fmt.Errorf("rpc error: %s", s.Message()) + } + return err + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..355d016 --- /dev/null +++ b/main_test.go @@ -0,0 +1,177 @@ +// PUBLIC DOMAIN NOTICE +// National Center for Biotechnology Information +// +// This software/database is a "United States Government Work" under the +// terms of the United States Copyright Act. It was written as part of +// the author's official duties as a United States Government employee and +// thus cannot be copyrighted. This software/database is freely available +// to the public for use. The National Library of Medicine and the U.S. +// Government have not placed any restriction on its use or reproduction. +// +// Although all reasonable efforts have been taken to ensure the accuracy +// and reliability of the software and data, the NLM and the U.S. +// Government do not and cannot warrant the performance or results that +// may be obtained by using this software or data. The NLM and the U.S. +// Government disclaim all warranties, express or implied, including +// warranties of performance, merchantability or fitness for any particular +// purpose. +// +// Please cite the author in any work or product based on this material. + +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli" +) + +func Test_createConfig_args_narg1(t *testing.T) { + // given + args := cli.Args{"server"} + flags := &appFlags{} + + // when + config, err := createConfig(flags, args) + + // then + assert.NoError(t, err) + assert.Equal(t, "server", config.serverAddress) + assert.Empty(t, config.serviceName) +} + +func Test_createConfig_args_narg2(t *testing.T) { + // given + args := cli.Args{"server", "svc"} + flags := &appFlags{} + + // when + config, err := createConfig(flags, args) + + // then + assert.NoError(t, err) + assert.Equal(t, "server", config.serverAddress) + assert.Equal(t, "svc", config.serviceName) +} + +func Test_createConfig_args_narg3(t *testing.T) { + // given + args := cli.Args{"foo", "bar", "baz"} + flags := &appFlags{} + + // when + _, err := createConfig(flags, args) + + // then + assert.Error(t, err) +} + +func Test_createConfig_args_narg0(t *testing.T) { + // given + args := cli.Args{} + flags := &appFlags{} + + // when + _, err := createConfig(flags, args) + + // then + assert.Error(t, err) +} + +func Test_createConfig_flags_empty(t *testing.T) { + // given + args := cli.Args{"foo"} + flags := &appFlags{} + + // when + config, err := createConfig(flags, args) + + // then + assert.NoError(t, err) + assert.Nil(t, config.creds) + assert.False(t, config.noFail) +} + +func Test_createConfig_flags(t *testing.T) { + // given + args := cli.Args{"foo"} + flags := &appFlags{ + tls: true, + noFail: true, + timeout: time.Minute, + } + + // when + config, err := createConfig(flags, args) + + // then + assert.NoError(t, err) + assert.NotNil(t, config.creds) + assert.Equal(t, time.Minute, config.timeout) + assert.True(t, config.noFail) +} + +func Test_parseCredentials_tls(t *testing.T) { + // given + dataset := []struct { + flags *appFlags + credsReturned bool + errorReturned bool + message string + }{ + // success + {&appFlags{tls: true}, true, false, ""}, + {&appFlags{tlsInsecure: true}, true, false, ""}, + {&appFlags{tlsCAFile: "acctest/x509/certificate.pem"}, true, false, ""}, + {&appFlags{tlsCAPath: "acctest/x509"}, true, false, ""}, + // fail + {&appFlags{tlsCAFile: "acctest/key.pem"}, false, true, "should fail, acctest/key.pem is not a valid certificate"}, + {&appFlags{tlsCAFile: "123098.pem"}, false, true, "should fail, 123098.pem does not exist"}, + {&appFlags{tls: true, tlsInsecure: true}, false, true, "only one tls option should be specified"}, + {&appFlags{tls: true, tlsCAFile: "acctest/x509/certificate.pem"}, false, true, "only one tls option should be specified"}, + {&appFlags{tlsInsecure: true, tlsCAFile: "acctest/x509/certificate.pem"}, false, true, "only one tls option should be specified"}, + {&appFlags{tlsCAFile: "acctest/x509/certificate.pem", tlsCAPath: "acctest/x509"}, false, true, "only one tls option should be specified"}, + } + + for _, tt := range dataset { + // when + creds, err := parseCredentials(tt.flags) + + // then + if tt.credsReturned { + assert.NotNil(t, creds, tt.message) + } else { + assert.Nil(t, creds, tt.message) + } + + if tt.errorReturned { + assert.Error(t, err, tt.message) + } else { + assert.NoError(t, err, tt.message) + } + } +} + +func Test_countTLSFlags(t *testing.T) { + // given + dataset := []struct { + flags *appFlags + count int + }{ + {&appFlags{}, 0}, + {&appFlags{tls: true}, 1}, + {&appFlags{tls: true, tlsInsecure: true}, 2}, + {&appFlags{tls: true, tlsInsecure: true, tlsCAFile: "file"}, 3}, + {&appFlags{tls: true, tlsInsecure: true, tlsCAFile: "file", tlsCAPath: "path"}, 4}, + } + + for _, tt := range dataset { + // when + cnt := countTLSFlags(tt.flags) + + // then + assert.Equal(t, tt.count, cnt) + } +} diff --git a/probe/probe.go b/probe/probe.go deleted file mode 100644 index 5d05126..0000000 --- a/probe/probe.go +++ /dev/null @@ -1,78 +0,0 @@ -// PUBLIC DOMAIN NOTICE -// National Center for Biotechnology Information -// -// This software/database is a "United States Government Work" under the -// terms of the United States Copyright Act. It was written as part of -// the author's official duties as a United States Government employee and -// thus cannot be copyrighted. This software/database is freely available -// to the public for use. The National Library of Medicine and the U.S. -// Government have not placed any restriction on its use or reproduction. -// -// Although all reasonable efforts have been taken to ensure the accuracy -// and reliability of the software and data, the NLM and the U.S. -// Government do not and cannot warrant the performance or results that -// may be obtained by using this software or data. The NLM and the U.S. -// Government disclaim all warranties, express or implied, including -// warranties of performance, merchantability or fitness for any particular -// purpose. -// -// Please cite the author in any work or product based on this material. - -package probe - -import ( - "context" - "google.golang.org/grpc" - hpb "google.golang.org/grpc/health/grpc_health_v1" - "time" -) - -var ( - // DialTimeout timeout for establishing connection to a gRPC server - DialTimeout time.Duration = 1 * time.Second - // OpTimeout timeout for performing gRPC calls - OpTimeout time.Duration = 1 * time.Second - - ctx = context.Background() - connection *grpc.ClientConn - client hpb.HealthClient -) - -// Connect to specified server -func Connect(serverAddresss string) (err error) { - lctx, cancel := context.WithTimeout(ctx, DialTimeout) - defer cancel() - connection, err = grpc.DialContext(lctx, serverAddresss, grpc.WithInsecure()) - client = hpb.NewHealthClient(connection) - return err -} - -// Disconnect from server -func Disconnect() { - connection.Close() - connection = nil -} - -// CheckServer checks health of the server overall -func CheckServer() (hpb.HealthCheckResponse_ServingStatus, error) { - return doCheck(&hpb.HealthCheckRequest{}) -} - -// CheckService checks health of a specific service on the server -// If nil is passed as argument, the effect is the same as if CheckServer() was called -func CheckService(serviceName string) (hpb.HealthCheckResponse_ServingStatus, error) { - return doCheck(&hpb.HealthCheckRequest{ - Service: serviceName, - }) -} - -func doCheck(request *hpb.HealthCheckRequest) (status hpb.HealthCheckResponse_ServingStatus, err error) { - lctx, cancel := context.WithTimeout(ctx, OpTimeout) - defer cancel() - - response, err := client.Check(lctx, request) - if response != nil { - status = response.Status - } - return -}