diff --git a/app.go b/app.go index b872a6a1ee40856ca2358fbfa0394443187ce916..c03397f0edff57ffed7705e5d850fba0eaf26bb9 100644 --- a/app.go +++ b/app.go @@ -20,6 +20,7 @@ import ( "gitlab.com/gitlab-org/gitlab-pages/internal/admin" "gitlab.com/gitlab-org/gitlab-pages/internal/artifact" "gitlab.com/gitlab-org/gitlab-pages/internal/auth" + "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/internal/domain" "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/internal/netutil" @@ -37,7 +38,7 @@ var ( ) type theApp struct { - appConfig + config.Config dm domain.Map lock sync.RWMutex Artifact *artifact.Artifact @@ -123,7 +124,7 @@ func (a *theApp) checkAuthenticationIfNotExists(domain *domain.D, w http.Respons func (a *theApp) tryAuxiliaryHandlers(w http.ResponseWriter, r *http.Request, https bool, host string, domain *domain.D) bool { // short circuit content serving to check for a status page - if r.RequestURI == a.appConfig.StatusPath { + if r.RequestURI == a.Config.StatusPath { a.healthCheck(w, r, https) return true } @@ -175,7 +176,7 @@ func (a *theApp) serveContent(ww http.ResponseWriter, r *http.Request, https boo } // Only for projects that have access control enabled - if domain.IsAccessControlEnabled(r) { + if domain.IsResourceProtected(r) { log.WithFields(log.Fields{ "host": r.Host, "path": r.RequestURI, @@ -299,7 +300,7 @@ func (a *theApp) Run() { a.listenAdminUnix(&wg) a.listenAdminHTTPS(&wg) - go domain.Watch(a.Domain, a.UpdateDomains, time.Second) + go domain.Watch(a.Domain, a.UpdateDomains, time.Second, &a.Config) wg.Wait() } @@ -353,8 +354,8 @@ func (a *theApp) listenAdminHTTPS(wg *sync.WaitGroup) { }() } -func runApp(config appConfig) { - a := theApp{appConfig: config} +func runApp(config config.Config) { + a := theApp{Config: config} if config.ArtifactsServer != "" { a.Artifact = artifact.New(config.ArtifactsServer, config.ArtifactsServerTimeout, config.Domain) diff --git a/daemon.go b/daemon.go index 2b3cc8c6c747ff40b6018e750760acc5acff6189..1ce567ac8525a5c05f2b8512def56282c4a68fe2 100644 --- a/daemon.go +++ b/daemon.go @@ -12,6 +12,7 @@ import ( "github.com/kardianos/osext" log "github.com/sirupsen/logrus" + "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/internal/jail" ) @@ -32,7 +33,7 @@ func daemonMain() { }).Info("starting the daemon as unprivileged user") // read the configuration from the pipe "ExtraFiles" - var config appConfig + var config config.Config if err := json.NewDecoder(os.NewFile(3, "options")).Decode(&config); err != nil { fatal(err) } @@ -199,7 +200,7 @@ func jailDaemon(cmd *exec.Cmd) (*jail.Jail, error) { return cage, nil } -func daemonize(config appConfig, uid, gid uint, inPlace bool) error { +func daemonize(config config.Config, uid, gid uint, inPlace bool) error { log.WithFields(log.Fields{ "uid": uid, "gid": gid, @@ -260,7 +261,7 @@ func daemonize(config appConfig, uid, gid uint, inPlace bool) error { return cmd.Wait() } -func updateFds(config *appConfig, cmd *exec.Cmd) { +func updateFds(config *config.Config, cmd *exec.Cmd) { for _, fds := range [][]uintptr{ config.ListenHTTP, config.ListenHTTPS, diff --git a/app_config.go b/internal/config/config.go similarity index 88% rename from app_config.go rename to internal/config/config.go index 9ff26b6b9d5e9c31ba78d16440764a62eb862e6c..f41619b6a053496e79de750721623eabcc659723 100644 --- a/app_config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ -package main +package config -type appConfig struct { +// Config contains main configuration variables +type Config struct { Domain string ArtifactsServer string ArtifactsServerTimeout int diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 1455d4368af7e9a3055230f0915a71fe2e7387da..b245da20e3dfe964ac838a3ca82f6af7e559dbc7 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -8,6 +8,7 @@ import ( "mime" "net" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -17,6 +18,9 @@ import ( "golang.org/x/sys/unix" + log "github.com/sirupsen/logrus" + + "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/internal/httputil" ) @@ -56,6 +60,8 @@ type D struct { certificate *tls.Certificate certificateError error certificateOnce sync.Once + + appConfig *config.Config } // String implements Stringer. @@ -116,6 +122,42 @@ func getHost(r *http.Request) string { return host } +func (d *D) isAcmeChallenge(path string) bool { + // Only allow acme challenges for custom domains + if d.config == nil { + return false + } + return strings.HasPrefix(path, "/.well-known/acme-challenge/") +} + +// This should be moved to additional config param +func (d *D) gitlabServer() string { + url, err := url.Parse(d.appConfig.ArtifactsServer) + if err != nil { + return "" + } + host, _, _ := net.SplitHostPort(url.Host) + return host +} + +func (d *D) redirectForAcmeChallenge(w http.ResponseWriter, r *http.Request) bool { + log.Debug("Get request for acme-challenge, redirecting to gitlab instance") + + host := getHost(r) + redirectPath := "//" + d.gitlabServer() + "/-/acme-challenge/" + host + "/" + filepath.Base(r.URL.Path) + http.Redirect(w, r, redirectPath, 302) + return true + +} + +func setContentType(w http.ResponseWriter, fullPath string) { + ext := filepath.Ext(fullPath) + ctype := mime.TypeByExtension(ext) + if ctype != "" { + w.Header().Set("Content-Type", ctype) + } +} + // Look up a project inside the domain based on the host and path. Returns the // project and its name (if applicable) func (d *D) getProjectWithSubpath(r *http.Request) (*project, string, string) { @@ -160,12 +202,16 @@ func (d *D) IsHTTPSOnly(r *http.Request) bool { return false } -// IsAccessControlEnabled figures out if the request is to a project that has access control enabled -func (d *D) IsAccessControlEnabled(r *http.Request) bool { +// IsResourceProtected figures out if the request is to a project that has access control enabled +func (d *D) IsResourceProtected(r *http.Request) bool { if d == nil { return false } + if d.isAcmeChallenge(r.URL.Path) { + return false + } + // Check custom domain config (e.g. http://example.com) if d.config != nil { return d.config.AccessControl @@ -273,7 +319,7 @@ func (d *D) serveFile(w http.ResponseWriter, r *http.Request, origPath string) e return err } - if !d.IsAccessControlEnabled(r) { + if !d.IsResourceProtected(r) { // Set caching headers w.Header().Set("Cache-Control", "max-age=600") w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123)) @@ -386,7 +432,9 @@ func (d *D) tryFile(w http.ResponseWriter, r *http.Request, projectName string, fullPath, err := d.resolvePath(projectName, subPath...) if locationError, _ := err.(*locationDirectoryError); locationError != nil { + log.WithField("file", locationError.FullPath).Debug("File not found") if endsWithSlash(r.URL.Path) { + log.Debug("Try to serve index.html") fullPath, err = d.resolvePath(projectName, filepath.Join(subPath...), "index.html") } else { // Concat Host with URL.Path @@ -405,6 +453,7 @@ func (d *D) tryFile(w http.ResponseWriter, r *http.Request, projectName string, } if err != nil { + log.WithError(err).Debug("Cant resolve path") return err } @@ -442,11 +491,20 @@ func (d *D) serveNotFoundFromGroup(w http.ResponseWriter, r *http.Request) { } func (d *D) serveFileFromConfig(w http.ResponseWriter, r *http.Request) bool { + log.WithFields(log.Fields{ + "project_name": d.projectName, + "path": r.URL.Path, + }).Debug("Serving file from config") // Try to serve file for http://host/... => /group/project/... - if d.tryFile(w, r, d.projectName, r.URL.Path) == nil { + err := d.tryFile(w, r, d.projectName, r.URL.Path) + if err == nil { return true } + if err != nil && d.isAcmeChallenge(r.URL.Path) { + d.redirectForAcmeChallenge(w, r) + } + return false } diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index add9b616a786a3fe54a0c3f94bc495e037720528..0269e3e5a8b7d8830d95e969fe8d2a6dbbfbc493 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/internal/fixture" ) @@ -471,6 +472,38 @@ func TestOpenNoFollow(t *testing.T) { require.Nil(t, link) } +func TestAcmeChallengeRedirect(t *testing.T) { + cleanup := setUpTests(t) + defer cleanup() + + testGroup := &D{ + projectName: "", + group: group{ + name: "group", + projects: map[string]*project{ + "project2": &project{}, + }, + }, + } + + testDomain := &D{ + group: group{name: "group"}, + projectName: "project2", + config: &domainConfig{ + Domain: "test.example.com", + }, + appConfig: &config.Config{ArtifactsServer: "example.com"}, + } + + testHTTP404(t, serveFileOrNotFound(testGroup), "GET", "http://group.test.io/project2/.well-known/acme-challenge/0123456789abcdef", nil, "The page you're looking for could not be found") + assert.HTTPBodyContains(t, serveFileOrNotFound(testGroup), "GET", "http://group.test.io/project2/.well-known/acme-challenge/existing-file", nil, "Yes, I really exist") + + assert.HTTPRedirect(t, serveFileOrNotFound(testDomain), "GET", "http://test.example.com/.well-known/acme-challenge/0123456789abcdef", nil) + assert.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "http://test.example.com/.well-known/acme-challenge/existing-file", nil, "Yes, I really exist") + assert.HTTPRedirect(t, serveFileOrNotFound(testDomain), "GET", "https://test.example.com/.well-known/acme-challenge/0123456789abcdef", nil) + assert.HTTPBodyContains(t, serveFileOrNotFound(testDomain), "GET", "https://test.example.com/.well-known/acme-challenge/existing-file", nil, "Yes, I really exist") +} + var chdirSet = false func setUpTests(t require.TestingT) func() { diff --git a/internal/domain/map.go b/internal/domain/map.go index 2891a272a91e7abaa021edf9bc32900b03679911..64a8e33477d0dc2f4521e9bfd77f054559ae2bd0 100644 --- a/internal/domain/map.go +++ b/internal/domain/map.go @@ -12,6 +12,7 @@ import ( "github.com/karrick/godirwalk" log "github.com/sirupsen/logrus" + "gitlab.com/gitlab-org/gitlab-pages/internal/config" "gitlab.com/gitlab-org/gitlab-pages/metrics" ) @@ -34,11 +35,12 @@ func (dm Map) updateDomainMap(domainName string, domain *D) { dm[domainName] = domain } -func (dm Map) addDomain(rootDomain, groupName, projectName string, config *domainConfig) { +func (dm Map) addDomain(rootDomain, groupName, projectName string, config *domainConfig, appConfig *config.Config) { newDomain := &D{ group: group{name: groupName}, projectName: projectName, config: config, + appConfig: appConfig, } var domainName string @@ -89,7 +91,7 @@ func (dm Map) updateGroupDomain(rootDomain, groupName, projectPath string, https dm[domainName] = groupDomain } -func (dm Map) readProjectConfig(rootDomain string, group, projectName string, config *domainsConfig) { +func (dm Map) readProjectConfig(rootDomain string, group, projectName string, config *domainsConfig, appConfig *config.Config) { if config == nil { // This is necessary to preserve the previous behaviour where a // group domain is created even if no config.json files are @@ -103,7 +105,7 @@ func (dm Map) readProjectConfig(rootDomain string, group, projectName string, co for _, domainConfig := range config.Domains { config := domainConfig // domainConfig is reused for each loop iteration if domainConfig.Valid(rootDomain) { - dm.addDomain(rootDomain, group, projectName, &config) + dm.addDomain(rootDomain, group, projectName, &config, appConfig) } } } @@ -167,7 +169,7 @@ type jobResult struct { } // ReadGroups walks the pages directory and populates dm with all the domains it finds. -func (dm Map) ReadGroups(rootDomain string, fis godirwalk.Dirents) { +func (dm Map) ReadGroups(rootDomain string, fis godirwalk.Dirents, appConfig *config.Config) { fanOutGroups := make(chan string) fanIn := make(chan jobResult) wg := &sync.WaitGroup{} @@ -200,7 +202,7 @@ func (dm Map) ReadGroups(rootDomain string, fis godirwalk.Dirents) { done := make(chan struct{}) go func() { for result := range fanIn { - dm.readProjectConfig(rootDomain, result.group, result.project, result.config) + dm.readProjectConfig(rootDomain, result.group, result.project, result.config, appConfig) } close(done) @@ -225,7 +227,7 @@ const ( ) // Watch polls the filesystem and kicks off a new domain directory scan when needed. -func Watch(rootDomain string, updater domainsUpdater, interval time.Duration) { +func Watch(rootDomain string, updater domainsUpdater, interval time.Duration, appConfig *config.Config) { lastUpdate := []byte("no-update") for { @@ -254,7 +256,7 @@ func Watch(rootDomain string, updater domainsUpdater, interval time.Duration) { continue } - dm.ReadGroups(rootDomain, fis) + dm.ReadGroups(rootDomain, fis, appConfig) duration := time.Since(started).Seconds() var hash string diff --git a/internal/domain/map_test.go b/internal/domain/map_test.go index dc5e8648dfd0bd2df7df5ad711e49ee16ddd7e86..a848e5bf0baab7b7186789e3184430602bd79202 100644 --- a/internal/domain/map_test.go +++ b/internal/domain/map_test.go @@ -12,6 +12,8 @@ import ( "github.com/karrick/godirwalk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-pages/internal/config" ) func getEntries(t *testing.T) godirwalk.Dirents { @@ -35,7 +37,7 @@ func TestReadProjects(t *testing.T) { defer cleanup() dm := make(Map) - dm.ReadGroups("test.io", getEntries(t)) + dm.ReadGroups("test.io", getEntries(t), &config.Config{}) var domains []string for d := range dm { @@ -90,7 +92,7 @@ func TestReadProjectsMaxDepth(t *testing.T) { defaultDomain := "test.io" dm := make(Map) - dm.ReadGroups(defaultDomain, getEntries(t)) + dm.ReadGroups(defaultDomain, getEntries(t), &config.Config{}) var domains []string for d := range dm { @@ -158,7 +160,7 @@ func TestWatch(t *testing.T) { update := make(chan Map) go Watch("gitlab.io", func(dm Map) { update <- dm - }, time.Microsecond*50) + }, time.Microsecond*50, &config.Config{}) defer os.Remove(updateFile) @@ -233,7 +235,7 @@ func BenchmarkReadGroups(b *testing.B) { var dm Map for i := 0; i < 2; i++ { dm = make(Map) - dm.ReadGroups("example.com", getEntriesForBenchmark(b)) + dm.ReadGroups("example.com", getEntriesForBenchmark(b), &config.Config{}) } b.Logf("found %d domains", len(dm)) }) diff --git a/main.go b/main.go index 126ad69ed5ee670c02c6118f4a180eb78b6e4e47..8872823952e80e5541050927712e8f8c703c528f 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,8 @@ import ( "github.com/namsral/flag" log "github.com/sirupsen/logrus" + + "gitlab.com/gitlab-org/gitlab-pages/internal/config" ) // VERSION stores the information about the semantic version of application @@ -71,8 +73,8 @@ var ( errRedirectURINotDefined = errors.New("auth-redirect-uri must be defined if authentication is supported") ) -func configFromFlags() appConfig { - var config appConfig +func configFromFlags() config.Config { + var config config.Config config.Domain = strings.ToLower(*pagesDomain) config.RedirectHTTP = *redirectHTTP @@ -131,7 +133,7 @@ func configFromFlags() appConfig { return config } -func checkAuthenticationConfig(config appConfig) { +func checkAuthenticationConfig(config config.Config) { if *secret != "" || *clientID != "" || *clientSecret != "" || *gitLabServer != "" || *redirectURI != "" { // Check all auth params are valid @@ -243,7 +245,7 @@ func closeAll(cs []io.Closer) { // createAppListeners returns net.Listener and *os.File instances. The // caller must ensure they don't get closed or garbage-collected (which // implies closing) too soon. -func createAppListeners(config *appConfig) []io.Closer { +func createAppListeners(config *config.Config) []io.Closer { var closers []io.Closer for _, addr := range listenHTTP.Split() { @@ -285,7 +287,7 @@ func createAppListeners(config *appConfig) []io.Closer { // createMetricsListener returns net.Listener and *os.File instances. The // caller must ensure they don't get closed or garbage-collected (which // implies closing) too soon. -func createMetricsListener(config *appConfig) []io.Closer { +func createMetricsListener(config *config.Config) []io.Closer { addr := *metricsAddress if addr == "" { return nil @@ -304,7 +306,7 @@ func createMetricsListener(config *appConfig) []io.Closer { // createAdminUnixListener returns net.Listener and *os.File instances. The // caller must ensure they don't get closed or garbage-collected (which // implies closing) too soon. -func createAdminUnixListener(config *appConfig) []io.Closer { +func createAdminUnixListener(config *config.Config) []io.Closer { unixPath := *adminUnixListener if unixPath == "" { return nil @@ -327,7 +329,7 @@ func createAdminUnixListener(config *appConfig) []io.Closer { // createAdminHTTPSListener returns net.Listener and *os.File instances. The // caller must ensure they don't get closed or garbage-collected (which // implies closing) too soon. -func createAdminHTTPSListener(config *appConfig) []io.Closer { +func createAdminHTTPSListener(config *config.Config) []io.Closer { addr := *adminHTTPSListener if addr == "" { return nil diff --git a/shared/pages/group/project2/public/.well-known/acme-challenge/existing-file b/shared/pages/group/project2/public/.well-known/acme-challenge/existing-file new file mode 100644 index 0000000000000000000000000000000000000000..dc6b3d555cf75d0d872c381e3869fbe9a1535870 --- /dev/null +++ b/shared/pages/group/project2/public/.well-known/acme-challenge/existing-file @@ -0,0 +1 @@ +Yes, I really exist \ No newline at end of file