diff options
author | Krasimir Angelov <kangelov@gitlab.com> | 2019-10-18 00:27:10 +0300 |
---|---|---|
committer | Krasimir Angelov <kangelov@gitlab.com> | 2019-11-12 03:50:52 +0300 |
commit | 46873b1439eb3bed30cbb834fde44ffede1f0371 (patch) | |
tree | 7db495db4ec7c2145535fbde3d5c1a2972081427 | |
parent | 7edf2444b7e4affcf370927edb1d7ee4c95d293d (diff) |
Add HTTP client to consume GitLab internal API for Pages
At the moment this supports only getting virtual domain configuration
for given host.
Related to https://gitlab.com/gitlab-org/gitlab-pages/issues/253.
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | internal/source/gitlab/client.go | 137 | ||||
-rw-r--r-- | internal/source/gitlab/client_test.go | 120 | ||||
-rw-r--r-- | internal/source/gitlab/response.go | 21 |
5 files changed, 281 insertions, 0 deletions
@@ -4,6 +4,7 @@ go 1.12 require ( github.com/certifi/gocertifi v0.0.0-20190905060710-a5e0173ced67 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835 github.com/getsentry/raven-go v0.1.2 // indirect github.com/golang/mock v1.3.1 @@ -17,6 +17,8 @@ github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835 h1:roDmqJ4Qes7hrDOsWsMCce0vQHz3xiMPjJ9m4c2eeNs= github.com/fzipp/gocyclo v0.0.0-20150627053110-6acd4345c835/go.mod h1:BjL/N0+C+j9uNX+1xcNuM9vdSIcXCZrQZUYbXOFbgN8= diff --git a/internal/source/gitlab/client.go b/internal/source/gitlab/client.go new file mode 100644 index 00000000..bd422a4e --- /dev/null +++ b/internal/source/gitlab/client.go @@ -0,0 +1,137 @@ +package gitlab + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "time" + + jwt "github.com/dgrijalva/jwt-go" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httptransport" +) + +// Client is a HTTP client to access Pages internal API +type Client struct { + secretKey []byte + baseURL *url.URL + httpClient *http.Client +} + +var ( + errUnknown = errors.New("Unknown") + errNoContent = errors.New("No Content") + errUnauthorized = errors.New("Unauthorized") + errNotFound = errors.New("Not Found") +) + +// NewClient initializes and returns new Client +func NewClient(baseURL string, secretKey []byte) (*Client, error) { + url, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + secretKey: secretKey, + baseURL: url, + httpClient: &http.Client{ + Timeout: 5 * time.Second, + Transport: httptransport.Transport, + }, + }, nil +} + +// GetVirtualDomain returns VirtualDomain configuration for the given host +func (gc *Client) GetVirtualDomain(host string) (*VirtualDomain, error) { + params := map[string]string{"host": host} + + resp, err := gc.get("/api/v4/internal/pages", params) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var domain VirtualDomain + err = json.NewDecoder(resp.Body).Decode(&domain) + if err != nil { + return nil, err + } + + return &domain, nil +} + +func (gc *Client) get(path string, params map[string]string) (*http.Response, error) { + endpoint, err := gc.endpoint(path, params) + if err != nil { + return nil, err + } + + req, err := gc.request("GET", endpoint) + if err != nil { + return nil, err + } + + resp, err := gc.httpClient.Do(req) + if err != nil { + return nil, err + } + + switch { + case resp.StatusCode == http.StatusOK: + return resp, nil + case resp.StatusCode == http.StatusNoContent: + return resp, errNoContent + case resp.StatusCode == http.StatusUnauthorized: + return resp, errUnauthorized + case resp.StatusCode == http.StatusNotFound: + return resp, errNotFound + default: + return resp, errUnknown + } +} + +func (gc *Client) endpoint(path string, params map[string]string) (*url.URL, error) { + endpoint, err := gc.baseURL.Parse(path) + if err != nil { + return nil, err + } + + values := url.Values{} + for key, value := range params { + values.Add(key, value) + } + endpoint.RawQuery = values.Encode() + + return endpoint, nil +} + +func (gc *Client) request(method string, endpoint *url.URL) (*http.Request, error) { + req, err := http.NewRequest("GET", endpoint.String(), nil) + if err != nil { + return nil, err + } + + token, err := gc.token() + if err != nil { + return nil, err + } + req.Header.Set("Gitlab-Pages-Api-Request", token) + + return req, nil +} + +func (gc *Client) token() (string, error) { + claims := jwt.StandardClaims{ + Issuer: "gitlab-pages", + ExpiresAt: time.Now().Add(1 * time.Minute).Unix(), + } + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(gc.secretKey) + if err != nil { + return "", err + } + + return token, nil +} diff --git a/internal/source/gitlab/client_test.go b/internal/source/gitlab/client_test.go new file mode 100644 index 00000000..d8c86fad --- /dev/null +++ b/internal/source/gitlab/client_test.go @@ -0,0 +1,120 @@ +package gitlab + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + jwt "github.com/dgrijalva/jwt-go" +) + +var ( + encodedSecret = "e41rcFh7XBA7sNABWVCe2AZvxMsy6QDtJ8S9Ql1UiN8=" // 32 bytes, base64 encoded +) + +func TestNewValidBaseURL(t *testing.T) { + _, err := NewClient("https://gitlab.com", secretKey()) + require.NoError(t, err) +} + +func TestNewInvalidBaseURL(t *testing.T) { + client, err := NewClient("%", secretKey()) + require.Error(t, err) + require.Nil(t, client) +} + +func TestGetVirtualDomainForErrorResponses(t *testing.T) { + tests := map[int]string{ + http.StatusNoContent: "No Content", + http.StatusUnauthorized: "Unauthorized", + http.StatusNotFound: "Not Found", + } + + for statusCode, expectedError := range tests { + name := fmt.Sprintf("%d %s", statusCode, expectedError) + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v4/internal/pages", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, secretKey()) + + actual, err := client.GetVirtualDomain("group.gitlab.io") + + require.EqualError(t, err, expectedError) + require.Nil(t, actual) + }) + } +} + +func TestGetVirtualDomainAuthenticatedRequest(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("/api/v4/internal/pages", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "group.gitlab.io", r.FormValue("host")) + + if checkRequest(r.Header.Get("Gitlab-Pages-Api-Request")) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"certificate":"foo","key":"bar","lookup_paths":[{"project_id":123,"access_control":false,"source":{"type":"file","path":"mygroup/myproject/public/"},"https_only":true,"prefix":"/myproject/"}]}`) + } else { + w.WriteHeader(http.StatusUnauthorized) + } + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client, _ := NewClient(server.URL, secretKey()) + + actual, err := client.GetVirtualDomain("group.gitlab.io") + require.NoError(t, err) + + require.Equal(t, "foo", actual.Certificate) + require.Equal(t, "bar", actual.Key) + + lookupPath := actual.LookupPaths[0] + require.Equal(t, 123, lookupPath.ProjectID) + require.Equal(t, false, lookupPath.AccessControl) + require.Equal(t, true, lookupPath.HTTPSOnly) + require.Equal(t, "/myproject/", lookupPath.Prefix) + + require.Equal(t, "file", lookupPath.Source.Type) + require.Equal(t, "mygroup/myproject/public/", lookupPath.Source.Path) +} + +func checkRequest(tokenString string) bool { + token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return secretKey(), nil + }) + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return false + } + + if _, ok := claims["exp"]; !ok { + return false + } + + return claims["iss"] == "gitlab-pages" +} + +func secretKey() []byte { + secretKey, _ := base64.StdEncoding.DecodeString(encodedSecret) + return secretKey +} diff --git a/internal/source/gitlab/response.go b/internal/source/gitlab/response.go new file mode 100644 index 00000000..20597362 --- /dev/null +++ b/internal/source/gitlab/response.go @@ -0,0 +1,21 @@ +package gitlab + +// LookupPath represents a lookup path for a GitLab Pages virtual domain +type LookupPath struct { + ProjectID int `json:"project_id,omitempty"` + AccessControl bool `json:"access_control,omitempty"` + HTTPSOnly bool `json:"https_only,omitempty"` + Prefix string `json:"prefix,omitempty"` + Source struct { + Type string `json:"type,omitempty"` + Path string `json:"path,omitempty"` + } +} + +// VirtualDomain represents a GitLab Pages virtual domain +type VirtualDomain struct { + Certificate string `json:"certificate,omitempty"` + Key string `json:"key,omitempty"` + + LookupPaths []LookupPath `json:"lookup_paths"` +} |