diff options
-rw-r--r-- | CHANGELOG | 6 | ||||
-rw-r--r-- | VERSION | 2 | ||||
-rw-r--r-- | app_config.go | 17 | ||||
-rw-r--r-- | go.mod | 1 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | internal/source/gitlab/client.go | 143 | ||||
-rw-r--r-- | internal/source/gitlab/client_test.go | 130 | ||||
-rw-r--r-- | internal/source/gitlab/response.go | 21 | ||||
-rw-r--r-- | main.go | 7 |
9 files changed, 315 insertions, 14 deletions
@@ -1,3 +1,9 @@ +v 1.12.0 + +- Add minimal support for the api-secret-key config flag (not yet used) +- Add warnings about secrets given through command-line flags +- Remove Admin gRPC api (was never used) + v 1.11.0 - Refactor domain package and extract disk serving !189 @@ -1 +1 @@ -1.11.0 +1.12.0 diff --git a/app_config.go b/app_config.go index b870626d..f9be6545 100644 --- a/app_config.go +++ b/app_config.go @@ -25,12 +25,13 @@ type appConfig struct { LogFormat string LogVerbose bool - StoreSecret string - GitLabServer string - ClientID string - ClientSecret string - RedirectURI string - SentryDSN string - SentryEnvironment string - CustomHeaders []string + StoreSecret string + GitLabServer string + GitLabAPISecretKey []byte + ClientID string + ClientSecret string + RedirectURI string + SentryDSN string + SentryEnvironment string + CustomHeaders []string } @@ -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 index b2dab32e..9dde7b43 100644 --- a/internal/source/gitlab/client.go +++ b/internal/source/gitlab/client.go @@ -1,9 +1,142 @@ package gitlab -import "context" +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "time" -// Client is an internal HTTP client used for communication with GitLab -// instance -type Client interface { - Resolve(ctx context.Context, domain string) (*Lookup, int, error) + 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 +// baseUrl is appConfig.GitLabServer +// secretKey is appConfig.GitLabAPISecretKey (not yet implemented) +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 resp != nil { + defer resp.Body.Close() + } + + if err != nil { + return nil, err + } + + 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(5 * time.Second).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..1d63a590 --- /dev/null +++ b/internal/source/gitlab/client_test.go @@ -0,0 +1,130 @@ +package gitlab + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "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) { + require.Equal(t, "GET", r.Method) + require.Equal(t, "group.gitlab.io", r.FormValue("host")) + + validateToken(t, r.Header.Get("Gitlab-Pages-Api-Request")) + + response := `{ + "certificate": "foo", + "key": "bar", + "lookup_paths": [ + { + "project_id": 123, + "access_control": false, + "source": { + "type": "file", + "path": "mygroup/myproject/public/" + }, + "https_only": true, + "prefix": "/myproject/" + } + ] + }` + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, response) + }) + + 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 validateToken(t *testing.T, tokenString string) { + token, err := 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 + }) + require.NoError(t, err) + + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + require.True(t, token.Valid) + require.NotNil(t, claims["exp"]) + require.Equal(t, "gitlab-pages", claims["iss"]) +} + +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"` +} @@ -57,6 +57,7 @@ var ( secret = flag.String("auth-secret", "", "Cookie store hash key, should be at least 32 bytes long.") gitLabAuthServer = flag.String("auth-server", "", "DEPRECATED, use gitlab-server instead. GitLab server, for example https://www.gitlab.com") gitLabServer = flag.String("gitlab-server", "", "GitLab server, for example https://www.gitlab.com") + gitLabAPISecretKey = flag.String("api-secret-key", "", "File with secret key used to authenticate with the GitLab API (NOT YET IMPLEMENTED)") clientID = flag.String("auth-client-id", "", "GitLab application Client ID") clientSecret = flag.String("auth-client-secret", "", "GitLab application Client Secret") redirectURI = flag.String("auth-redirect-uri", "", "GitLab application redirect URI") @@ -143,6 +144,7 @@ func configFromFlags() appConfig { }{ {&config.RootCertificate, *pagesRootCert}, {&config.RootKey, *pagesRootKey}, + {&config.GitLabAPISecretKey, *gitLabAPISecretKey}, } { if file.path != "" { *file.contents = readFile(file.path) @@ -237,9 +239,14 @@ func loadConfig() appConfig { "tls-max-version": *tlsMaxVersion, "use-http-2": config.HTTP2, "gitlab-server": config.GitLabServer, + "api-secret-key": *gitLabAPISecretKey, "auth-redirect-uri": config.RedirectURI, }).Debug("Start daemon with configuration") + if *gitLabAPISecretKey != "" { + log.Warn("api-secret-key parameter is a placeholder for future developments, this option will be ignored.") + } + return config } |