diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 24672648b..5be68a9e9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -85,3 +85,51 @@ jobs: uses: actions/checkout@v2 - name: build run: docker build . + + integration-tests: + name: Integration Test + runs-on: ubuntu-latest + steps: + - name: cache binaries + uses: actions/cache@v1 + env: + cache-name: cache-binaries + with: + path: /opt/binaries/ + key: ${{ runner.os }}-binaries + - name: install binaries + run: | + #!/bin/bash + sudo mkdir -p /usr/local/bin/ + sudo mkdir -p /opt/minikube/bin/ + cd /opt/minikube/bin + + if [ ! -f minikube ]; then + echo "downloading minikube" + sudo curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 + sudo chmod +x minikube + fi + sudo install minikube /usr/local/bin/ + + if [ ! -f mkcert ]; then + echo "downloading mkcert" + sudo curl -Lo mkcert https://github.com/FiloSottile/mkcert/releases/download/v1.4.1/mkcert-v1.4.1-linux-amd64 + sudo chmod +x mkcert + fi + sudo install mkcert /usr/local/bin/ + - name: start minikube + run: | + minikube start + kubectl cluster-info + + - name: install go + uses: actions/setup-go@v1 + with: + go-version: 1.14.x + - name: checkout code + uses: actions/checkout@v2 + - name: build dev docker image + run: | + ./scripts/build-dev-docker.bash + - name: test + run: go test -v diff --git a/go.mod b/go.mod index 88c4afa9c..be75445ed 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,12 @@ require ( github.com/golang/mock v1.4.3 github.com/golang/protobuf v1.3.5 github.com/google/go-cmp v0.4.0 + github.com/google/go-jsonnet v0.15.0 + github.com/google/uuid v1.1.1 github.com/gorilla/mux v1.7.4 github.com/mitchellh/hashstructure v1.0.0 github.com/onsi/ginkgo v1.11.0 // indirect + github.com/onsi/gocleanup v0.0.0-20140331211545-c1a5478700b5 github.com/onsi/gomega v1.8.1 // indirect github.com/open-policy-agent/opa v0.18.0 github.com/pelletier/go-toml v1.6.0 // indirect @@ -34,10 +37,12 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.6.3 + github.com/stretchr/testify v1.4.0 github.com/uber/jaeger-client-go v2.20.1+incompatible // indirect go.etcd.io/bbolt v1.3.4 go.opencensus.io v0.22.3 golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc + golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d google.golang.org/api v0.20.0 google.golang.org/appengine v1.6.5 // indirect diff --git a/go.sum b/go.sum index 22e940d0b..02cfb5f77 100644 --- a/go.sum +++ b/go.sum @@ -119,11 +119,15 @@ github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-jsonnet v0.15.0 h1:lEUXTDnVsHu+CLLzMeWAdWV4JpCgkJeDqdVNS8RtyuY= +github.com/google/go-jsonnet v0.15.0/go.mod h1:ex9QcU8vzXQUDeNe4gaN1uhGQbTYpOeZ6AbWdy6JbX4= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -192,8 +196,11 @@ github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzR github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-runewidth v0.0.0-20181025052659-b20a3daf6a39 h1:0E3wlIAcvD6zt/8UJgTd4JMT6UQhsnYyjCIqllyVLbs= github.com/mattn/go-runewidth v0.0.0-20181025052659-b20a3daf6a39/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= @@ -221,6 +228,8 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gocleanup v0.0.0-20140331211545-c1a5478700b5 h1:uuhPqmc+m7Nj7btxZEjdEUv+uFoBHNf2Tk/E7gGM+kY= +github.com/onsi/gocleanup v0.0.0-20140331211545-c1a5478700b5/go.mod h1:tHaogb+iP6wJXwCqVUlmxYuJb4XDyEKxxs3E4DvMBK0= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34= github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= @@ -297,6 +306,8 @@ github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= @@ -367,8 +378,6 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200117160349-530e935923ad h1:Jh8cai0fqIK+f6nG0UgPW5wFk8wmiMhM3AyciDBdtQg= golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 h1:fpnn/HnJONpIu6hkXi1u/7rR0NzilgWr4T0JmWkEitk= -golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc h1:ZGI/fILM2+ueot/UixBSoj9188jCAxVHEZEGhqq67I4= golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -437,11 +446,13 @@ golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 000000000..d65c5b82e --- /dev/null +++ b/integration/README.md @@ -0,0 +1,10 @@ +# Integration Tests +These tests are full end-to-end integration tests using Pomerium in a kubernetes cluster. + +## Usage +The following applications are needed: + +* `kubectl`: to apply the manifests to kubernetes +* `mkcert`: to generate a root CA and wildcard certificates + +The test suite will apply the manifests to your current Kubernetes context before running the tests. diff --git a/integration/authorization_test.go b/integration/authorization_test.go new file mode 100644 index 000000000..b4f5c1f1f --- /dev/null +++ b/integration/authorization_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "net/http" + "net/url" + "testing" + "time" + + "github.com/pomerium/pomerium/integration/internal/flows" + "github.com/stretchr/testify/assert" +) + +func TestAuthorization(t *testing.T) { + ctx, clearTimeout := context.WithTimeout(mainCtx, time.Second*30) + defer clearTimeout() + + t.Run("public", func(t *testing.T) { + t.Skip() // pomerium doesn't currently handle unauthenticated public routes + + client := testcluster.NewHTTPClient() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://httpdetails.localhost.pomerium.io", nil) + if err != nil { + t.Fatal(err) + } + + res, err := client.Do(req) + if !assert.NoError(t, err, "unexpected http error") { + return + } + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode, "unexpected status code, headers=%v", res.Header) + }) + + t.Run("domains", func(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), "bob@dogs.test", []string{"user"}) + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for dogs.test") + } + }) + t.Run("not allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), "joe@cats.test", []string{"user"}) + if assert.NoError(t, err) { + assertDeniedAccess(t, res, "expected Forbidden for cats.test") + } + }) + }) + t.Run("users", func(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), "bob@dogs.test", []string{"user"}) + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for bob@dogs.test") + } + }) + t.Run("not allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), "joe@cats.test", []string{"user"}) + if assert.NoError(t, err) { + assertDeniedAccess(t, res, "expected Forbidden for joe@cats.test") + } + }) + }) + t.Run("groups", func(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), "bob@dogs.test", []string{"admin", "user"}) + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for admin") + } + }) + t.Run("not allowed", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), "joe@cats.test", []string{"user"}) + if assert.NoError(t, err) { + assertDeniedAccess(t, res, "expected Forbidden for user") + } + }) + }) +} + +func mustParseURL(str string) *url.URL { + u, err := url.Parse(str) + if err != nil { + panic(err) + } + return u +} + +func assertDeniedAccess(t *testing.T, res *http.Response, msgAndArgs ...interface{}) bool { + return assert.Condition(t, func() bool { + return res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized + }, msgAndArgs...) +} diff --git a/integration/backends/httpdetails/index.js b/integration/backends/httpdetails/index.js new file mode 100644 index 000000000..04612f15a --- /dev/null +++ b/integration/backends/httpdetails/index.js @@ -0,0 +1,29 @@ +const http = require("http"); + +const requestListener = function (req, res) { + const { + pathname: path, + hostname: host, + port: port, + search: query, + hash: hash, + } = new URL(req.url, `http://${req.headers.host}`); + + res.setHeader("Content-Type", "application/json"); + res.writeHead(200); + res.end( + JSON.stringify({ + headers: req.headers, + method: req.method, + host: host, + port: port, + path: path, + query: query, + hash: hash, + }) + ); +}; + +const server = http.createServer(requestListener); +console.log("starting http server on :8080"); +server.listen(8080); diff --git a/integration/dashboard_test.go b/integration/dashboard_test.go new file mode 100644 index 000000000..84a83d1bc --- /dev/null +++ b/integration/dashboard_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDashboard(t *testing.T) { + ctx := mainCtx + ctx, clearTimeout := context.WithTimeout(ctx, time.Second*30) + defer clearTimeout() + + t.Run("image asset", func(t *testing.T) { + client := testcluster.NewHTTPClient() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://httpdetails.localhost.pomerium.io/.pomerium/assets/img/pomerium.svg", nil) + if err != nil { + t.Fatal(err) + } + + res, err := client.Do(req) + if !assert.NoError(t, err, "unexpected http error") { + return + } + defer res.Body.Close() + + assert.Equal(t, http.StatusOK, res.StatusCode, "unexpected status code") + assert.Equal(t, "image/svg+xml", res.Header.Get("Content-Type")) + }) +} diff --git a/integration/internal/cluster/certs.go b/integration/internal/cluster/certs.go new file mode 100644 index 000000000..be41ab1bf --- /dev/null +++ b/integration/internal/cluster/certs.go @@ -0,0 +1,65 @@ +package cluster + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +type TLSCerts struct { + CA string + Cert string + Key string +} + +func bootstrapCerts(ctx context.Context) (*TLSCerts, error) { + err := run(ctx, "mkcert", withArgs("-install")) + if err != nil { + return nil, fmt.Errorf("error install root certificate: %w", err) + } + + var buf bytes.Buffer + err = run(ctx, "mkcert", withArgs("-CAROOT"), withStdout(&buf)) + if err != nil { + return nil, fmt.Errorf("error running mkcert") + } + + caPath := strings.TrimSpace(buf.String()) + ca, err := ioutil.ReadFile(filepath.Join(caPath, "rootCA.pem")) + if err != nil { + return nil, fmt.Errorf("error reading root ca: %w", err) + } + + wd := filepath.Join(os.TempDir(), uuid.New().String()) + err = os.MkdirAll(wd, 0755) + if err != nil { + return nil, fmt.Errorf("error creating temporary directory: %w", err) + } + + err = run(ctx, "mkcert", withArgs("*.localhost.pomerium.io"), withWorkingDir(wd)) + if err != nil { + return nil, fmt.Errorf("error generating certificates: %w", err) + } + + cert, err := ioutil.ReadFile(filepath.Join(wd, "_wildcard.localhost.pomerium.io.pem")) + if err != nil { + return nil, fmt.Errorf("error reading certificate: %w", err) + } + + key, err := ioutil.ReadFile(filepath.Join(wd, "_wildcard.localhost.pomerium.io-key.pem")) + if err != nil { + return nil, fmt.Errorf("error reading certificate key: %w", err) + } + + return &TLSCerts{ + CA: string(ca), + Cert: string(cert), + Key: string(key), + }, nil +} diff --git a/integration/internal/cluster/cluster.go b/integration/internal/cluster/cluster.go new file mode 100644 index 000000000..dfca3c3cd --- /dev/null +++ b/integration/internal/cluster/cluster.go @@ -0,0 +1,46 @@ +package cluster + +import ( + "net/http" + "net/http/cookiejar" + + "github.com/rs/zerolog/log" + "golang.org/x/net/publicsuffix" +) + +type Cluster struct { + workingDir string + + transport http.RoundTripper + certs *TLSCerts +} + +func New(workingDir string) *Cluster { + return &Cluster{ + workingDir: workingDir, + } +} + +func (cluster *Cluster) NewHTTPClient() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + panic(err) + } + return &http.Client{ + Transport: &loggingRoundTripper{cluster.transport}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Jar: jar, + } +} + +type loggingRoundTripper struct { + http.RoundTripper +} + +func (rt *loggingRoundTripper) RoundTrip(req *http.Request) (res *http.Response, err error) { + res, err = rt.RoundTripper.RoundTrip(req) + log.Debug().Str("method", req.Method).Str("url", req.URL.String()).Msg("http request") + return res, err +} diff --git a/integration/internal/cluster/cmd.go b/integration/internal/cluster/cmd.go new file mode 100644 index 000000000..aa8286336 --- /dev/null +++ b/integration/internal/cluster/cmd.go @@ -0,0 +1,70 @@ +package cluster + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + + "github.com/rs/zerolog/log" +) + +type cmdOption func(*exec.Cmd) + +func withArgs(args ...string) cmdOption { + return func(cmd *exec.Cmd) { + cmd.Args = append([]string{"kubectl"}, args...) + } +} + +func withStdin(rdr io.Reader) cmdOption { + return func(cmd *exec.Cmd) { + cmd.Stdin = rdr + } +} + +func withStdout(w io.Writer) cmdOption { + return func(cmd *exec.Cmd) { + cmd.Stdout = w + } +} + +func withWorkingDir(wd string) cmdOption { + return func(cmd *exec.Cmd) { + cmd.Dir = wd + } +} + +func run(ctx context.Context, name string, options ...cmdOption) error { + cmd := commandContext(ctx, name) + for _, o := range options { + o(cmd) + } + if cmd.Stderr == nil { + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe for %s: %w", name, err) + } + go cmdLogger(stderr) + defer stderr.Close() + } + if cmd.Stdout == nil { + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe for %s: %w", name, err) + } + go cmdLogger(stdout) + defer stdout.Close() + } + + log.Debug().Strs("args", cmd.Args).Msgf("running %s", name) + return cmd.Run() +} + +func cmdLogger(rdr io.Reader) { + s := bufio.NewScanner(rdr) + for s.Scan() { + log.Debug().Msg(s.Text()) + } +} diff --git a/integration/internal/cluster/cmd_linux.go b/integration/internal/cluster/cmd_linux.go new file mode 100644 index 000000000..b18d0e9e7 --- /dev/null +++ b/integration/internal/cluster/cmd_linux.go @@ -0,0 +1,24 @@ +// +build linux + +package cluster + +import ( + "context" + "os/exec" + "syscall" + + "github.com/onsi/gocleanup" +) + +func commandContext(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + } + gocleanup.Register(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + }) + return cmd +} diff --git a/integration/internal/cluster/cmd_notlinux.go b/integration/internal/cluster/cmd_notlinux.go new file mode 100644 index 000000000..aa8200be2 --- /dev/null +++ b/integration/internal/cluster/cmd_notlinux.go @@ -0,0 +1,20 @@ +// +build !linux + +package cluster + +import ( + "context" + "os/exec" + + "github.com/onsi/gocleanup" +) + +func commandContext(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + gocleanup.Register(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + }) + return cmd +} diff --git a/integration/internal/cluster/config.go b/integration/internal/cluster/config.go new file mode 100644 index 000000000..3e07385f2 --- /dev/null +++ b/integration/internal/cluster/config.go @@ -0,0 +1,6 @@ +package cluster + +type Config struct { + WorkingDirectory string + HTTPSPort int +} diff --git a/integration/internal/cluster/setup.go b/integration/internal/cluster/setup.go new file mode 100644 index 000000000..3016afc80 --- /dev/null +++ b/integration/internal/cluster/setup.go @@ -0,0 +1,222 @@ +package cluster + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/go-jsonnet" + "github.com/pomerium/pomerium/integration/internal/httputil" + "github.com/rs/zerolog/log" +) + +var requiredDeployments = []string{ + "default/httpbin", + "default/httpecho", + "default/openid", + "default/pomerium-authenticate", + "default/pomerium-authorize", + "default/pomerium-proxy", + "ingress-nginx/nginx-ingress-controller", +} + +// Setup configures the test cluster so that it is ready for the integration tests. +func (cluster *Cluster) Setup(ctx context.Context) error { + err := run(ctx, "kubectl", withArgs("cluster-info")) + if err != nil { + return fmt.Errorf("error running kubectl cluster-info: %w", err) + } + + cluster.certs, err = bootstrapCerts(ctx) + if err != nil { + return err + } + + jsonsrc, err := cluster.generateManifests(ctx) + if err != nil { + return err + } + + err = applyManifests(ctx, jsonsrc) + if err != nil { + return err + } + + hostport, err := cluster.getNodeHTTPSAddr(ctx) + if err != nil { + return err + } + + cluster.transport = httputil.NewLocalRoundTripper(&http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, map[string]string{ + "443": hostport, + }) + + return nil +} + +func (cluster *Cluster) getNodeHTTPSAddr(ctx context.Context) (hostport string, err error) { + var buf bytes.Buffer + + args := []string{"get", "service", "--namespace", "ingress-nginx", "--output", "json", + "ingress-nginx-nodeport"} + err = run(ctx, "kubectl", withArgs(args...), withStdout(&buf)) + if err != nil { + return "", fmt.Errorf("error getting service details with kubectl: %w", err) + } + + var svcResult struct { + Spec struct { + Ports []struct { + Name string `json:"name"` + NodePort int `json:"nodePort"` + } `json:"ports"` + Selector map[string]string `json:"selector"` + } `json:"spec"` + } + err = json.Unmarshal(buf.Bytes(), &svcResult) + if err != nil { + return "", fmt.Errorf("error unmarshaling service details from kubectl: %w", err) + } + + buf.Reset() + + args = []string{"get", "pods", "--namespace", "ingress-nginx", "--output", "json"} + var sel []string + for k, v := range svcResult.Spec.Selector { + sel = append(sel, k+"="+v) + } + args = append(args, "--selector", strings.Join(sel, ",")) + err = run(ctx, "kubectl", withArgs(args...), withStdout(&buf)) + if err != nil { + return "", fmt.Errorf("error getting pod details with kubectl: %w", err) + } + + var podsResult struct { + Items []struct { + Status struct { + HostIP string `json:"hostIP"` + } `json:"status"` + } `json:"items"` + } + err = json.Unmarshal(buf.Bytes(), &podsResult) + if err != nil { + return "", fmt.Errorf("error unmarshaling pod details from kubectl (json=%s): %w", buf.String(), err) + } + + var port string + for _, p := range svcResult.Spec.Ports { + if p.Name == "https" { + port = strconv.Itoa(p.NodePort) + } + } + if port == "" { + return "", fmt.Errorf("failed to find https port in kubectl service results (result=%v)", svcResult) + } + + var hostIP string + for _, item := range podsResult.Items { + hostIP = item.Status.HostIP + } + if hostIP == "" { + return "", fmt.Errorf("failed to find host ip in kubectl pod results: %w", err) + } + + return net.JoinHostPort(hostIP, port), nil +} + +func (cluster *Cluster) generateManifests(ctx context.Context) (string, error) { + src, err := ioutil.ReadFile(filepath.Join(cluster.workingDir, "manifests", "manifests.jsonnet")) + if err != nil { + return "", fmt.Errorf("error reading manifest jsonnet src: %w", err) + } + + vm := jsonnet.MakeVM() + vm.ExtVar("tls-ca", cluster.certs.CA) + vm.ExtVar("tls-cert", cluster.certs.Cert) + vm.ExtVar("tls-key", cluster.certs.Key) + vm.Importer(&jsonnet.FileImporter{ + JPaths: []string{filepath.Join(cluster.workingDir, "manifests")}, + }) + jsonsrc, err := vm.EvaluateSnippet("manifests.jsonnet", string(src)) + if err != nil { + return "", fmt.Errorf("error evaluating jsonnet (filename=manifests.jsonnet): %w", err) + } + + return jsonsrc, nil +} + +func applyManifests(ctx context.Context, jsonsrc string) error { + err := run(ctx, "kubectl", withArgs("apply", "-f", "-"), withStdin(strings.NewReader(jsonsrc))) + if err != nil { + return fmt.Errorf("error applying manifests: %w", err) + } + + log.Info().Msg("waiting for deployments to come up") + ctx, clearTimeout := context.WithTimeout(ctx, 5*time.Minute) + defer clearTimeout() + ticker := time.NewTicker(time.Second * 5) + defer ticker.Stop() + for { + var buf bytes.Buffer + err = run(ctx, "kubectl", withArgs("get", "deployments", "--all-namespaces", "--output", "json"), + withStdout(&buf)) + if err != nil { + return fmt.Errorf("error polling for deployment status: %w", err) + } + + var results struct { + Items []struct { + Metadata struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + } `json:"metadata"` + Status struct { + AvailableReplicas int `json:"availableReplicas"` + } `json:"status"` + } `json:"items"` + } + err = json.Unmarshal(buf.Bytes(), &results) + if err != nil { + return fmt.Errorf("error unmarshaling kubectl results: %w", err) + } + + byName := map[string]int{} + for _, item := range results.Items { + byName[item.Metadata.Namespace+"/"+item.Metadata.Name] = item.Status.AvailableReplicas + } + + done := true + for _, dep := range requiredDeployments { + if byName[dep] < 1 { + done = false + log.Warn().Str("deployment", dep).Msg("deployment is not ready yet") + } + } + if done { + break + } + + select { + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + <-ticker.C + } + log.Info().Msg("all deployments are ready") + + return nil +} diff --git a/integration/internal/flows/flows.go b/integration/internal/flows/flows.go new file mode 100644 index 000000000..aea0f56cc --- /dev/null +++ b/integration/internal/flows/flows.go @@ -0,0 +1,131 @@ +package flows + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/pomerium/pomerium/integration/internal/forms" +) + +const ( + authenticateHostname = "authenticate.localhost.pomerium.io" + openidHostname = "openid.localhost.pomerium.io" + pomeriumCallbackPath = "/.pomerium/callback/" +) + +// Authenticate submits a request to a URL, expects a redirect to authenticate and then openid and logs in. +// Finally it expects to redirect back to the original page. +func Authenticate(ctx context.Context, client *http.Client, url *url.URL, email string, groups []string) (*http.Response, error) { + originalHostname := url.Hostname() + + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) + if err != nil { + return nil, err + } + + var res *http.Response + + // (1) redirect to authenticate + for req.URL.Hostname() == originalHostname { + res, err = client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + req, err = requestFromRedirectResponse(ctx, res, req) + if err != nil { + return nil, fmt.Errorf("expected redirect to %s: %w", authenticateHostname, err) + } + } + + // (2) redirect to openid + for req.URL.Hostname() == authenticateHostname { + res, err = client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + req, err = requestFromRedirectResponse(ctx, res, req) + if err != nil { + return nil, fmt.Errorf("expected redirect to %s: %w", openidHostname, err) + } + } + + // (3) submit the form + for req.URL.Hostname() == openidHostname { + res, err = client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + forms := forms.Parse(res.Body) + if len(forms) > 0 { + f := forms[0] + f.Inputs["email"] = email + f.Inputs["groups"] = strings.Join(groups, ",") + req, err = f.NewRequestWithContext(ctx, req.URL) + if err != nil { + return nil, err + } + } else { + req, err = requestFromRedirectResponse(ctx, res, req) + if err != nil { + return nil, fmt.Errorf("expected redirect to %s: %w", openidHostname, err) + } + } + } + + // (4) back to authenticate + for req.URL.Hostname() == authenticateHostname { + res, err = client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + req, err = requestFromRedirectResponse(ctx, res, req) + if err != nil { + return nil, fmt.Errorf("expected redirect to %s: %w", originalHostname, err) + } + } + + // (5) finally to callback + if req.URL.Path != pomeriumCallbackPath { + return nil, fmt.Errorf("expected to redirect back to %s, but got %s", pomeriumCallbackPath, req.URL.String()) + } + + res, err = client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + req, err = requestFromRedirectResponse(ctx, res, req) + if err != nil { + return nil, fmt.Errorf("expected redirect to %s: %w", originalHostname, err) + } + + return client.Do(req) +} + +func requestFromRedirectResponse(ctx context.Context, res *http.Response, req *http.Request) (*http.Request, error) { + if res.Header.Get("Location") == "" { + return nil, fmt.Errorf("no location header found in response headers") + } + location, err := url.Parse(res.Header.Get("Location")) + if err != nil { + return nil, err + } + location = req.URL.ResolveReference(location) + newreq, err := http.NewRequestWithContext(ctx, "GET", location.String(), nil) + if err != nil { + return nil, err + } + return newreq, nil +} diff --git a/integration/internal/forms/forms.go b/integration/internal/forms/forms.go new file mode 100644 index 000000000..df255b685 --- /dev/null +++ b/integration/internal/forms/forms.go @@ -0,0 +1,91 @@ +package forms + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + + "golang.org/x/net/html" +) + +// A Form represents an HTML form. +type Form struct { + Action string + Method string + Inputs map[string]string +} + +// Parse parses all the forms in an HTML document. +func Parse(r io.Reader) []Form { + root, err := html.Parse(r) + if err != nil { + return nil + } + + var forms []Form + var currentForm *Form + var visit func(*html.Node) + visit = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "form" { + currentForm = &Form{Action: "", Method: "GET", Inputs: make(map[string]string)} + for _, attr := range node.Attr { + switch attr.Key { + case "action": + currentForm.Action = attr.Val + case "method": + currentForm.Method = strings.ToUpper(attr.Val) + } + } + } + + if currentForm != nil && node.Type == html.ElementNode && node.Data == "input" { + var name, value string + for _, attr := range node.Attr { + switch attr.Key { + case "name": + name = attr.Val + case "value": + value = attr.Val + } + } + if name != "" { + currentForm.Inputs[name] = value + } + } + + for c := node.FirstChild; c != nil; c = c.NextSibling { + visit(c) + } + if node.Type == html.ElementNode && node.Data == "form" { + if currentForm != nil { + forms = append(forms, *currentForm) + } + currentForm = nil + } + } + visit(root) + return forms +} + +func (f *Form) NewRequestWithContext(ctx context.Context, baseURL *url.URL) (*http.Request, error) { + actionURL, err := url.Parse(f.Action) + if err != nil { + return nil, err + } + actionURL = baseURL.ResolveReference(actionURL) + + vs := make(url.Values) + for k, v := range f.Inputs { + vs.Set(k, v) + } + + req, err := http.NewRequestWithContext(ctx, f.Method, actionURL.String(), strings.NewReader(vs.Encode())) + if err != nil { + return nil, err + } + //TODO: handle multipart forms + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req, nil +} diff --git a/integration/internal/httputil/httputil.go b/integration/internal/httputil/httputil.go new file mode 100644 index 000000000..486e02b63 --- /dev/null +++ b/integration/internal/httputil/httputil.go @@ -0,0 +1,44 @@ +package httputil + +import ( + "context" + "net" + "net/http" +) + +type localRoundTripper struct { + underlying http.RoundTripper + portToAddr map[string]string +} + +func NewLocalRoundTripper(underlying http.RoundTripper, portToAddr map[string]string) http.RoundTripper { + lrt := &localRoundTripper{underlying: underlying, portToAddr: portToAddr} + return lrt +} + +func (lrt *localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Host = lrt.remapHost(req.Context(), req.Host) + return lrt.underlying.RoundTrip(req) +} + +func (lrt *localRoundTripper) remapHost(ctx context.Context, hostport string) string { + host, port, err := net.SplitHostPort(hostport) + if err != nil { + host = hostport + port = "443" + } + + dst, ok := lrt.portToAddr[port] + if !ok { + return hostport + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(ips) == 0 || ips[0].String() != "127.0.0.1" { + return hostport + } + + return dst + +} diff --git a/integration/main_test.go b/integration/main_test.go new file mode 100644 index 000000000..fafcc77b2 --- /dev/null +++ b/integration/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "flag" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/onsi/gocleanup" + "github.com/pomerium/pomerium/integration/internal/cluster" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +const localHTTPSPort = 9443 + +var ( + mainCtx context.Context + testcluster *cluster.Cluster +) + +func TestMain(m *testing.M) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + flag.Parse() + if testing.Verbose() { + log.Logger = log.Logger.Level(zerolog.DebugLevel) + } else { + log.Logger = log.Logger.Level(zerolog.InfoLevel) + } + + mainCtx = context.Background() + var cancel func() + mainCtx, cancel = context.WithCancel(mainCtx) + var clearTimeout func() + mainCtx, clearTimeout = context.WithTimeout(mainCtx, time.Minute*10) + defer clearTimeout() + + _, mainTestFilePath, _, _ := runtime.Caller(0) + testcluster = cluster.New(filepath.Dir(mainTestFilePath)) + if err := testcluster.Setup(mainCtx); err != nil { + log.Fatal().Err(err).Send() + } + + status := m.Run() + cancel() + gocleanup.Cleanup() + os.Exit(status) +} diff --git a/integration/manifests/lib/httpdetails.libsonnet b/integration/manifests/lib/httpdetails.libsonnet new file mode 100644 index 000000000..1cc5bfb7b --- /dev/null +++ b/integration/manifests/lib/httpdetails.libsonnet @@ -0,0 +1,79 @@ +{ + apiVersion: 'v1', + kind: 'List', + items: [ + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + namespace: 'default', + name: 'httpdetails', + labels: { + app: 'httpdetails', + }, + }, + data: { + 'index.js': importstr '../../backends/httpdetails/index.js', + }, + }, + { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'default', + name: 'httpdetails', + labels: { app: 'httpdetails' }, + }, + spec: { + selector: { app: 'httpdetails' }, + ports: [{ + name: 'http', + port: 80, + targetPort: 'http', + }], + }, + }, + { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + namespace: 'default', + name: 'httpdetails', + }, + spec: { + replicas: 1, + selector: { matchLabels: { app: 'httpdetails' } }, + template: { + metadata: { + labels: { app: 'httpdetails' }, + }, + spec: { + containers: [{ + name: 'httpbin', + image: 'node:14-stretch-slim', + imagePullPolicy: 'IfNotPresent', + args: [ + 'node', + '/app/index.js', + ], + ports: [{ + name: 'http', + containerPort: 8080, + }], + volumeMounts: [{ + name: 'httpdetails', + mountPath: '/app', + }], + }], + volumes: [{ + name: 'httpdetails', + configMap: { + name: 'httpdetails', + }, + }], + }, + }, + }, + }, + ], +} diff --git a/integration/manifests/lib/nginx-ingress-controller.libsonnet b/integration/manifests/lib/nginx-ingress-controller.libsonnet new file mode 100644 index 000000000..018de0fd6 --- /dev/null +++ b/integration/manifests/lib/nginx-ingress-controller.libsonnet @@ -0,0 +1,143 @@ +{ + apiVersion: 'v1', + kind: 'List', + items: [ + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'ingress-nginx' }, + }, + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-configuration', namespace: 'ingress-nginx' }, + }, + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'tcp-services', namespace: 'ingress-nginx' }, + }, + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'udp-services', namespace: 'ingress-nginx' }, + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-serviceaccount', namespace: 'ingress-nginx' }, + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1beta1', + kind: 'ClusterRole', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-clusterrole' }, + rules: [{ apiGroups: [''], resources: ['configmaps', 'endpoints', 'nodes', 'pods', 'secrets'], verbs: ['list', 'watch'] }, { apiGroups: [''], resources: ['nodes'], verbs: ['get'] }, { apiGroups: [''], resources: ['services'], verbs: ['get', 'list', 'watch'] }, { apiGroups: [''], resources: ['events'], verbs: ['create', 'patch'] }, { apiGroups: ['extensions', 'networking.k8s.io'], resources: ['ingresses'], verbs: ['get', 'list', 'watch'] }, { apiGroups: ['extensions', 'networking.k8s.io'], resources: ['ingresses/status'], verbs: ['update'] }], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1beta1', + kind: 'Role', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-role', namespace: 'ingress-nginx' }, + rules: [{ apiGroups: [''], resources: ['configmaps', 'pods', 'secrets', 'namespaces'], verbs: ['get'] }, { apiGroups: [''], resourceNames: ['ingress-controller-leader-nginx'], resources: ['configmaps'], verbs: ['get', 'update'] }, { apiGroups: [''], resources: ['configmaps'], verbs: ['create'] }, { apiGroups: [''], resources: ['endpoints'], verbs: ['get'] }], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1beta1', + kind: 'RoleBinding', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-role-nisa-binding', namespace: 'ingress-nginx' }, + roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'Role', name: 'nginx-ingress-role' }, + subjects: [{ kind: 'ServiceAccount', name: 'nginx-ingress-serviceaccount', namespace: 'ingress-nginx' }], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1beta1', + kind: 'ClusterRoleBinding', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-clusterrole-nisa-binding' }, + roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'nginx-ingress-clusterrole' }, + subjects: [{ kind: 'ServiceAccount', name: 'nginx-ingress-serviceaccount', namespace: 'ingress-nginx' }], + }, + { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'nginx-ingress-controller', namespace: 'ingress-nginx' }, + spec: { + replicas: 1, + selector: { matchLabels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' } }, + template: { + metadata: { annotations: { 'prometheus.io/port': '10254', 'prometheus.io/scrape': 'true' }, labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' } }, + spec: { + containers: [{ + name: 'nginx-ingress-controller', + image: 'quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.30.0', + args: [ + '/nginx-ingress-controller', + '--configmap=$(POD_NAMESPACE)/nginx-configuration', + '--tcp-services-configmap=$(POD_NAMESPACE)/tcp-services', + '--udp-services-configmap=$(POD_NAMESPACE)/udp-services', + '--publish-service=$(POD_NAMESPACE)/ingress-nginx', + '--annotations-prefix=nginx.ingress.kubernetes.io', + '--v=2', + ], + env: [ + { name: 'POD_NAME', valueFrom: { fieldRef: { fieldPath: 'metadata.name' } } }, + { name: 'POD_NAMESPACE', valueFrom: { fieldRef: { fieldPath: 'metadata.namespace' } } }, + ], + lifecycle: { preStop: { exec: { command: ['/wait-shutdown'] } } }, + livenessProbe: { failureThreshold: 3, httpGet: { path: '/healthz', port: 10254, scheme: 'HTTP' }, initialDelaySeconds: 10, periodSeconds: 10, successThreshold: 1, timeoutSeconds: 10 }, + ports: [{ containerPort: 80, name: 'http', protocol: 'TCP' }, { containerPort: 443, name: 'https', protocol: 'TCP' }], + readinessProbe: { failureThreshold: 3, httpGet: { path: '/healthz', port: 10254, scheme: 'HTTP' }, periodSeconds: 10, successThreshold: 1, timeoutSeconds: 10 }, + securityContext: { allowPrivilegeEscalation: true, capabilities: { add: ['NET_BIND_SERVICE'], drop: ['ALL'] }, runAsUser: 101 }, + }], + nodeSelector: { 'kubernetes.io/os': 'linux' }, + serviceAccountName: 'nginx-ingress-serviceaccount', + terminationGracePeriodSeconds: 300, + }, + }, + }, + }, + { + apiVersion: 'v1', + kind: 'LimitRange', + metadata: { labels: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, name: 'ingress-nginx', namespace: 'ingress-nginx' }, + spec: { limits: [{ min: { cpu: '100m', memory: '90Mi' }, type: 'Container' }] }, + }, + { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'ingress-nginx', + name: 'ingress-nginx', + labels: { + 'app.kubernetes.io/name': 'ingress-nginx', + 'app.kubernetes.io/part-of': 'ingress-nginx', + }, + }, + spec: { + type: 'ClusterIP', + clusterIP: '10.96.1.1', + selector: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, + ports: [ + { name: 'http', port: 80, protocol: 'TCP', targetPort: 'http' }, + { name: 'https', port: 443, protocol: 'TCP', targetPort: 'https' }, + ], + }, + }, + { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'ingress-nginx', + name: 'ingress-nginx-nodeport', + labels: { + 'app.kubernetes.io/name': 'ingress-nginx', + 'app.kubernetes.io/part-of': 'ingress-nginx', + }, + }, + spec: { + type: 'NodePort', + selector: { 'app.kubernetes.io/name': 'ingress-nginx', 'app.kubernetes.io/part-of': 'ingress-nginx' }, + ports: [ + { name: 'http', port: 80, protocol: 'TCP', targetPort: 'http', nodePort: 30080 }, + { name: 'https', port: 443, protocol: 'TCP', targetPort: 'https', nodePort: 30443 }, + ], + }, + }, + ], +} diff --git a/integration/manifests/lib/pomerium.libsonnet b/integration/manifests/lib/pomerium.libsonnet new file mode 100644 index 000000000..19b173f22 --- /dev/null +++ b/integration/manifests/lib/pomerium.libsonnet @@ -0,0 +1,337 @@ +local tls = import './tls.libsonnet'; + +local PomeriumPolicy = function() [ + { + from: 'http://httpdetails.localhost.pomerium.io', + prefix: '/by-domain', + to: 'http://httpdetails.default.svc.cluster.local', + allowed_domains: ['dogs.test'], + }, + { + from: 'http://httpdetails.localhost.pomerium.io', + prefix: '/by-user', + to: 'http://httpdetails.default.svc.cluster.local', + allowed_users: ['bob@dogs.test'], + }, + { + from: 'http://httpdetails.localhost.pomerium.io', + prefix: '/by-group', + to: 'http://httpdetails.default.svc.cluster.local', + allowed_groups: ['admin'], + }, + { + from: 'http://httpdetails.localhost.pomerium.io', + to: 'http://httpdetails.default.svc.cluster.local', + allow_public_unauthenticated_access: true, + }, +]; + +local PomeriumPolicyHash = std.base64(std.md5(std.manifestJsonEx(PomeriumPolicy(), ''))); + +local PomeriumTLSSecret = function() { + apiVersion: 'v1', + kind: 'Secret', + type: 'kubernetes.io/tls', + metadata: { + namespace: 'default', + name: 'pomerium-tls', + }, + data: { + 'tls.crt': std.base64(tls.cert), + 'tls.key': std.base64(tls.key), + }, +}; + +local PomeriumCAsConfigMap = function() { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + namespace: 'default', + name: 'pomerium-cas', + labels: { + 'app.kubernetes.io/part-of': 'pomerium', + }, + }, + data: { + 'pomerium.crt': tls.ca, + }, +}; + +local PomeriumConfigMap = function() { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + namespace: 'default', + name: 'pomerium', + labels: { + 'app.kubernetes.io/part-of': 'pomerium', + }, + }, + data: { + ADDRESS: ':443', + GRPC_ADDRESS: ':5080', + GRPC_INSECURE: 'true', + DEBUG: 'true', + LOG_LEVEL: 'debug', + + AUTHENTICATE_SERVICE_URL: 'https://authenticate.localhost.pomerium.io', + AUTHENTICATE_CALLBACK_PATH: '/oauth2/callback', + AUTHORIZE_SERVICE_URL: 'http://authorize.default.svc.cluster.local:5080', + CACHE_SERVICE_URL: 'http://cache.default.svc.cluster.local:5080', + FORWARD_AUTH_URL: 'https://forward-authenticate.localhost.pomerium.io', + + SHARED_SECRET: 'Wy+c0uSuIM0yGGXs82MBwTZwRiZ7Ki2T0LANnmzUtkI=', + COOKIE_SECRET: 'eZ91a/j9fhgki9zPDU5zHdQWX4io89pJanChMVa5OoM=', + + CERTIFICATE: std.base64(tls.cert), + CERTIFICATE_KEY: std.base64(tls.key), + + IDP_PROVIDER: 'oidc', + IDP_PROVIDER_URL: 'https://openid.localhost.pomerium.io', + IDP_CLIENT_ID: 'pomerium-authenticate', + IDP_CLIENT_SECRET: 'pomerium-authenticate-secret', + + POLICY: std.base64(std.manifestYamlDoc(PomeriumPolicy())), + }, +}; + +local PomeriumDeployment = function(svc) { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + namespace: 'default', + name: 'pomerium-' + svc, + labels: { + app: 'pomerium-' + svc, + 'app.kubernetes.io/part-of': 'pomerium', + }, + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: 'pomerium-' + svc, + }, + }, + template: { + metadata: { + labels: { + app: 'pomerium-' + svc, + 'app.kubernetes.io/part-of': 'pomerium', + }, + annotations: { + 'policy-version': PomeriumPolicyHash, + }, + }, + spec: { + hostAliases: [{ + ip: '10.96.1.1', + hostnames: [ + 'openid.localhost.pomerium.io', + ], + }], + initContainers: [{ + name: 'pomerium-' + svc + '-certs', + image: 'buildpack-deps:buster-curl', + imagePullPolicy: 'Always', + command: ['sh', '-c', ||| + cp /incoming-certs/* /usr/local/share/ca-certificates + update-ca-certificates + |||], + volumeMounts: [ + { + name: 'incoming-certs', + mountPath: '/incoming-certs', + }, + { + name: 'outgoing-certs', + mountPath: '/etc/ssl/certs', + }, + ], + }], + containers: [{ + name: 'pomerium-' + svc, + image: 'pomerium/pomerium:dev', + imagePullPolicy: 'IfNotPresent', + envFrom: [{ + configMapRef: { name: 'pomerium' }, + }], + env: [{ + name: 'SERVICES', + value: svc, + }], + ports: [ + { name: 'https', containerPort: 443 }, + { name: 'grpc', containerPort: 5080 }, + ], + volumeMounts: [ + { + name: 'outgoing-certs', + mountPath: '/etc/ssl/certs', + }, + ], + }], + volumes: [ + { + name: 'incoming-certs', + configMap: { + name: 'pomerium-cas', + }, + }, + { + name: 'outgoing-certs', + emptyDir: {}, + }, + ], + }, + }, + }, +}; + +local PomeriumService = function(svc) { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'default', + name: svc, + labels: { + app: 'pomerium-' + svc, + 'app.kubernetes.io/part-of': 'pomerium', + }, + }, + spec: { + ports: [ + { + name: 'https', + port: 443, + targetPort: 'https', + }, + { + name: 'grpc', + port: 5080, + targetPort: 'grpc', + }, + ], + selector: { + app: 'pomerium-' + svc, + }, + }, +}; + +local PomeriumIngress = function() { + local proxyHosts = [ + 'forward-authenticate.localhost.pomerium.io', + 'httpecho.localhost.pomerium.io', + 'httpdetails.localhost.pomerium.io', + ], + + apiVersion: 'extensions/v1beta1', + kind: 'Ingress', + metadata: { + namespace: 'default', + name: 'pomerium', + annotations: { + 'kubernetes.io/ingress.class': 'nginx', + 'nginx.ingress.kubernetes.io/backend-protocol': 'HTTPS', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '16k', + }, + }, + spec: { + tls: [ + { + hosts: [ + 'authenticate.localhost.pomerium.io', + ] + proxyHosts, + secretName: 'pomerium-tls', + }, + ], + rules: [ + { + host: 'authenticate.localhost.pomerium.io', + http: { + paths: [ + { + path: '/', + backend: { + serviceName: 'authenticate', + servicePort: 'https', + }, + }, + ], + }, + }, + ] + [{ + host: host, + http: { + paths: [{ + path: '/', + backend: { + serviceName: 'proxy', + servicePort: 'https', + }, + }], + }, + } for host in proxyHosts], + }, +}; + +local PomeriumForwardAuthIngress = function() { + apiVersion: 'extensions/v1beta1', + kind: 'Ingress', + metadata: { + namespace: 'default', + name: 'pomerium-fa', + annotations: { + 'kubernetes.io/ingress.class': 'nginx', + 'nginx.ingress.kubernetes.io/auth-url': 'https://forward-authenticate.localhost.pomerium.io/verify?uri=$scheme://$host$request_uri', + 'nginx.ingress.kubernetes.io/auth-signin': 'https://forward-authenticate.localhost.pomerium.io/?uri=$scheme://$host$request_uri', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '16k', + }, + }, + spec: { + tls: [ + { + hosts: [ + 'fa-httpecho.localhost.pomerium.io', + ], + secretName: 'pomerium-tls', + }, + ], + rules: [ + { + host: 'fa-httpecho.localhost.pomerium.io', + http: { + paths: [ + { + path: '/', + backend: { + serviceName: 'httpecho', + servicePort: 'http', + }, + }, + ], + }, + }, + ], + }, +}; + +{ + apiVersion: 'v1', + kind: 'List', + items: [ + PomeriumConfigMap(), + PomeriumCAsConfigMap(), + PomeriumTLSSecret(), + PomeriumService('authenticate'), + PomeriumDeployment('authenticate'), + PomeriumService('authorize'), + PomeriumDeployment('authorize'), + PomeriumService('cache'), + PomeriumDeployment('cache'), + PomeriumService('proxy'), + PomeriumDeployment('proxy'), + PomeriumIngress(), + PomeriumForwardAuthIngress(), + ], +} diff --git a/integration/manifests/lib/reference-openid-provider.libsonnet b/integration/manifests/lib/reference-openid-provider.libsonnet new file mode 100644 index 000000000..10875318a --- /dev/null +++ b/integration/manifests/lib/reference-openid-provider.libsonnet @@ -0,0 +1,106 @@ +local Service = function() { + apiVersion: 'v1', + kind: 'Service', + metadata: { + namespace: 'default', + name: 'openid', + labels: { + app: 'openid', + 'app.kubernetes.io/part-of': 'openid', + }, + }, + spec: { + selector: { app: 'openid' }, + ports: [ + { + name: 'http', + port: 80, + targetPort: 'http', + }, + ], + }, +}; + +local Deployment = function() { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + namespace: 'default', + name: 'openid', + labels: { + app: 'openid', + 'app.kubernetes.io/part-of': 'openid', + }, + }, + spec: { + replicas: 1, + selector: { matchLabels: { app: 'openid' } }, + template: { + metadata: { + labels: { + app: 'openid', + 'app.kubernetes.io/part-of': 'openid', + }, + }, + spec: { + containers: [{ + name: 'openid', + image: 'quay.io/calebdoxsey/reference-openid-provider:latest', + imagePullPolicy: 'IfNotPresent', + ports: [ + { name: 'http', containerPort: 6080 }, + ], + }], + }, + }, + }, +}; + +local Ingress = function() { + apiVersion: 'extensions/v1beta1', + kind: 'Ingress', + metadata: { + namespace: 'default', + name: 'openid', + annotations: { + 'kubernetes.io/ingress.class': 'nginx', + 'nginx.ingress.kubernetes.io/backend-protocol': 'HTTP', + }, + }, + spec: { + tls: [ + { + hosts: [ + 'openid.localhost.pomerium.io', + ], + secretName: 'pomerium-tls', + }, + ], + rules: [ + { + host: 'openid.localhost.pomerium.io', + http: { + paths: [ + { + path: '/', + backend: { + serviceName: 'openid', + servicePort: 'http', + }, + }, + ], + }, + }, + ], + }, +}; + +{ + apiVersion: 'v1', + kind: 'List', + items: [ + Service(), + Deployment(), + Ingress(), + ], +} diff --git a/integration/manifests/lib/tls.libsonnet b/integration/manifests/lib/tls.libsonnet new file mode 100644 index 000000000..5d91910ad --- /dev/null +++ b/integration/manifests/lib/tls.libsonnet @@ -0,0 +1,5 @@ +{ + cert: std.extVar('tls-cert'), + key: std.extVar('tls-key'), + ca: std.extVar('tls-ca'), +} diff --git a/integration/manifests/manifests.jsonnet b/integration/manifests/manifests.jsonnet new file mode 100644 index 000000000..470d51ff4 --- /dev/null +++ b/integration/manifests/manifests.jsonnet @@ -0,0 +1,10 @@ +local httpdetails = import './lib/httpdetails.libsonnet'; +local nginxIngressController = import './lib/nginx-ingress-controller.libsonnet'; +local pomerium = import './lib/pomerium.libsonnet'; +local openid = import './lib/reference-openid-provider.libsonnet'; + +{ + apiVersion: 'v1', + kind: 'List', + items: nginxIngressController.items + pomerium.items + openid.items + httpdetails.items, +}