Custom domains for pages !48
10 files changed, +253 −7
Layout: unified · split
cmd/gitbay/main.go +5
| @@ -258,6 +258,11 @@ func repoCmd() *cobra.Command { | |||
| 258 | pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}), | 258 | pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}), |
| 259 | pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}), | 259 | pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}), |
| 260 | ), | 260 | ), |
| 261 | group("domain", "custom domains for the pages branch", | ||
| 262 | pass("add", "serve pages on a domain: <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}), | ||
| 263 | pass("list", "list custom pages domains", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}), | ||
| 264 | pass("remove", "remove a custom pages domain: <domain>", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}), | ||
| 265 | ), | ||
| 261 | group("topics", "free-form repository tags", | 266 | group("topics", "free-form repository tags", |
| 262 | pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}), | 267 | pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}), |
| 263 | pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}), | 268 | pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}), |
cmd/gitbayd/main.go +4
| @@ -192,6 +192,10 @@ func serveCmd() *cobra.Command { | |||
| 192 | return nil | 192 | return nil |
| 193 | } | 193 | } |
| 194 | } | 194 | } |
| 195 | // Custom pages domains: certs only for claimed hosts. | ||
| 196 | if _, err := st.PageDomainRepo(h); err == nil { | ||
| 197 | return nil | ||
| 198 | } | ||
| 195 | return fmt.Errorf("host %q not served here", h) | 199 | return fmt.Errorf("host %q not served here", h) |
| 196 | } | 200 | } |
| 197 | m := &autocert.Manager{ | 201 | m := &autocert.Manager{ |
e2e/pages_test.go +52
| @@ -121,4 +121,56 @@ func TestPages(t *testing.T) { | |||
| 121 | if status, _ := inst.get(t, "/explore"); status != 200 { | 121 | if status, _ := inst.get(t, "/explore"); status != 200 { |
| 122 | t.Fatalf("forge routes broken: %d", status) | 122 | t.Fatalf("forge routes broken: %d", status) |
| 123 | } | 123 | } |
| 124 | |||
| 125 | // --- custom domains --- | ||
| 126 | bobKey := inst.newKey(t, "bob") | ||
| 127 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 128 | |||
| 129 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 4 { | ||
| 130 | t.Fatal("non-admin claimed a domain") | ||
| 131 | } | ||
| 132 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 0 { | ||
| 133 | t.Fatalf("domain add: %s", errOut) | ||
| 134 | } | ||
| 135 | // The whole path maps into the repo's pages branch, no /<repo>/ prefix. | ||
| 136 | resp, body = inst.pagesGet(t, "docs.example.org", "/") | ||
| 137 | if resp.StatusCode != 200 || !strings.Contains(body, "project site") { | ||
| 138 | t.Fatalf("custom domain root: %d\n%s", resp.StatusCode, body) | ||
| 139 | } | ||
| 140 | if resp, _ = inst.pagesGet(t, "docs.example.org", "/style.css"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/css") { | ||
| 141 | t.Fatalf("custom domain css: %s", resp.Header.Get("Content-Type")) | ||
| 142 | } | ||
| 143 | if resp.Header.Get("Content-Security-Policy") != "" { | ||
| 144 | t.Fatal("forge CSP on a custom-domain response") | ||
| 145 | } | ||
| 146 | // Claims are exclusive, without naming the holder. | ||
| 147 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/other"); code != 0 { | ||
| 148 | t.Fatalf("bob repo: %s", errOut) | ||
| 149 | } | ||
| 150 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/other", "docs.example.org"); code != 2 || strings.Contains(errOut, "alice") { | ||
| 151 | t.Fatalf("duplicate claim: exit %d, %s", code, errOut) | ||
| 152 | } | ||
| 153 | // The forge host and bad domains are refused; private repos refused. | ||
| 154 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "gitbay.test"); code != 2 { | ||
| 155 | t.Fatal("claimed the forge host") | ||
| 156 | } | ||
| 157 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "sub.p.test"); code != 2 { | ||
| 158 | t.Fatal("claimed the built-in pages domain") | ||
| 159 | } | ||
| 160 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/secret", "priv.example.org"); code != 2 { | ||
| 161 | t.Fatal("private repo got a domain") | ||
| 162 | } | ||
| 163 | // repo show lists it; removal stops serving. | ||
| 164 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/site") | ||
| 165 | if !strings.Contains(out, "pages domains: docs.example.org") { | ||
| 166 | t.Fatalf("repo show missing domains:\n%s", out) | ||
| 167 | } | ||
| 168 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "remove", "alice/site", "docs.example.org"); code != 0 { | ||
| 169 | t.Fatal("domain remove failed") | ||
| 170 | } | ||
| 171 | // An unmapped host falls through to the forge (default-vhost), so the | ||
| 172 | // site content specifically must be gone. | ||
| 173 | if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") { | ||
| 174 | t.Fatal("removed domain still serves") | ||
| 175 | } | ||
| 124 | } | 176 | } |
internal/control/pagescmd.go added +105
| @@ -0,0 +1,105 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/policy" | ||
| 11 | "gitbay.org/gitbay/internal/protocol" | ||
| 12 | "gitbay.org/gitbay/internal/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func init() { | ||
| 16 | register(Command{Path: []string{"repo", "domain", "add"}, | ||
| 17 | Summary: "serve pages on a custom domain: repo domain add <owner/name> <domain>", Run: runDomainAdd}) | ||
| 18 | register(Command{Path: []string{"repo", "domain", "remove"}, | ||
| 19 | Summary: "remove a custom pages domain: repo domain remove <owner/name> <domain>", Run: runDomainRemove}) | ||
| 20 | register(Command{Path: []string{"repo", "domain", "list"}, | ||
| 21 | Summary: "list custom pages domains: repo domain list <owner/name>", ReadOnly: true, Run: runDomainList}) | ||
| 22 | } | ||
| 23 | |||
| 24 | // hostnamePat is a conservative DNS hostname: dot-separated labels, | ||
| 25 | // lowercase, at least two labels. | ||
| 26 | var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) | ||
| 27 | |||
| 28 | func validatePageDomain(c *Ctx, domain string) error { | ||
| 29 | if !hostnamePat.MatchString(domain) { | ||
| 30 | return fmt.Errorf("invalid domain %q: lowercase hostname like docs.example.org", domain) | ||
| 31 | } | ||
| 32 | if domain == c.Cfg.SiteHost() || strings.HasSuffix(c.Cfg.SiteHost(), "."+domain) { | ||
| 33 | return errors.New("that is the forge's own host: pages content must stay off its origin") | ||
| 34 | } | ||
| 35 | if pd := c.Cfg.Pages.Domain; pd != "" && (domain == pd || strings.HasSuffix(domain, "."+pd)) { | ||
| 36 | return fmt.Errorf("%s is under the built-in pages domain; it is served automatically", domain) | ||
| 37 | } | ||
| 38 | return nil | ||
| 39 | } | ||
| 40 | |||
| 41 | func runDomainAdd(c *Ctx, args []string) int { | ||
| 42 | if len(args) != 2 { | ||
| 43 | return c.fail(protocol.ExitUsage, "usage: repo domain add <owner/name> <domain>") | ||
| 44 | } | ||
| 45 | domain := strings.ToLower(args[1]) | ||
| 46 | if err := validatePageDomain(c, domain); err != nil { | ||
| 47 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 48 | } | ||
| 49 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 50 | if code >= 0 { | ||
| 51 | return code | ||
| 52 | } | ||
| 53 | if repo.Visibility != "public" { | ||
| 54 | return c.fail(protocol.ExitUsage, "pages serve public repositories only; %s is private", repo.Path()) | ||
| 55 | } | ||
| 56 | if err := c.Store.AddPageDomain(domain, repo.ID); err != nil { | ||
| 57 | if errors.Is(err, store.ErrExists) { | ||
| 58 | // Not naming the holder: domain claims must not enumerate repos. | ||
| 59 | return c.fail(protocol.ExitUsage, "%s is already claimed on this instance", domain) | ||
| 60 | } | ||
| 61 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 62 | } | ||
| 63 | return c.emit(map[string]string{"domain": domain}, func(w io.Writer) { | ||
| 64 | fmt.Fprintf(w, "%s now serves %s's pages branch — point its DNS (A/AAAA) at this server\n", domain, repo.Path()) | ||
| 65 | }) | ||
| 66 | } | ||
| 67 | |||
| 68 | func runDomainRemove(c *Ctx, args []string) int { | ||
| 69 | if len(args) != 2 { | ||
| 70 | return c.fail(protocol.ExitUsage, "usage: repo domain remove <owner/name> <domain>") | ||
| 71 | } | ||
| 72 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 73 | if code >= 0 { | ||
| 74 | return code | ||
| 75 | } | ||
| 76 | domain := strings.ToLower(args[1]) | ||
| 77 | if err := c.Store.RemovePageDomain(domain, repo.ID); err != nil { | ||
| 78 | if errors.Is(err, store.ErrNotFound) { | ||
| 79 | return c.fail(protocol.ExitNotFound, "%s is not a domain of %s", domain, repo.Path()) | ||
| 80 | } | ||
| 81 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 82 | } | ||
| 83 | return c.emit(map[string]string{"removed": domain}, func(w io.Writer) { | ||
| 84 | fmt.Fprintf(w, "removed %s\n", domain) | ||
| 85 | }) | ||
| 86 | } | ||
| 87 | |||
| 88 | func runDomainList(c *Ctx, args []string) int { | ||
| 89 | if len(args) != 1 { | ||
| 90 | return c.fail(protocol.ExitUsage, "usage: repo domain list <owner/name>") | ||
| 91 | } | ||
| 92 | repo, code := resolveRepo(c, args[0], policy.CanRead) | ||
| 93 | if code >= 0 { | ||
| 94 | return code | ||
| 95 | } | ||
| 96 | ds, err := c.Store.ListPageDomains(repo.ID) | ||
| 97 | if err != nil { | ||
| 98 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 99 | } | ||
| 100 | return c.emit(ds, func(w io.Writer) { | ||
| 101 | for _, d := range ds { | ||
| 102 | fmt.Fprintln(w, d) | ||
| 103 | } | ||
| 104 | }) | ||
| 105 | } | ||
internal/control/repo.go +6 −1
| @@ -244,6 +244,7 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 244 | ProtectedBranches []string `json:"protected_branches,omitempty"` | 244 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 245 | Archived bool `json:"archived,omitempty"` | 245 | Archived bool `json:"archived,omitempty"` |
| 246 | Topics []string `json:"topics,omitempty"` | 246 | Topics []string `json:"topics,omitempty"` |
| 247 | Domains []string `json:"domains,omitempty"` | ||
| 247 | Mirrors []mirrorOut `json:"mirrors,omitempty"` | 248 | Mirrors []mirrorOut `json:"mirrors,omitempty"` |
| 248 | } | 249 | } |
| 249 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) | 250 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) |
| @@ -251,8 +252,9 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 251 | if err != nil { | 252 | if err != nil { |
| 252 | return c.fail(protocol.ExitFailure, "%v", err) | 253 | return c.fail(protocol.ExitFailure, "%v", err) |
| 253 | } | 254 | } |
| 255 | domains, _ := c.Store.ListPageDomains(repo.ID) | ||
| 254 | d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch, | 256 | d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch, |
| 255 | repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, nil} | 257 | repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil} |
| 256 | // Mirror status is admin-only, like repo mirror list. The token never | 258 | // Mirror status is admin-only, like repo mirror list. The token never |
| 257 | // leaves the server. | 259 | // leaves the server. |
| 258 | if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) { | 260 | if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) { |
| @@ -282,6 +284,9 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 282 | if len(d.ProtectedBranches) > 0 { | 284 | if len(d.ProtectedBranches) > 0 { |
| 283 | fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) | 285 | fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) |
| 284 | } | 286 | } |
| 287 | if len(d.Domains) > 0 { | ||
| 288 | fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", ")) | ||
| 289 | } | ||
| 285 | for _, m := range d.Mirrors { | 290 | for _, m := range d.Mirrors { |
| 286 | status := "ok" | 291 | status := "ok" |
| 287 | if m.Pending { | 292 | if m.Pending { |
internal/httpd/pages.go +13 −1
| @@ -21,12 +21,24 @@ const PagesBranch = "refs/heads/pages" | |||
| 21 | // a separate origin where the forge has no cookies to protect. | 21 | // a separate origin where the forge has no cookies to protect. |
| 22 | func (s *Server) pagesRouter(forge http.Handler) http.Handler { | 22 | func (s *Server) pagesRouter(forge http.Handler) http.Handler { |
| 23 | domain := s.cfg.Pages.Domain | 23 | domain := s.cfg.Pages.Domain |
| 24 | siteHost := s.cfg.SiteHost() | ||
| 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | host := hostOnly(r.Host) | 26 | host := hostOnly(r.Host) |
| 26 | if host == domain || strings.HasSuffix(host, "."+domain) { | 27 | if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) { |
| 27 | s.servePage(w, r, host) | 28 | s.servePage(w, r, host) |
| 28 | return | 29 | return |
| 29 | } | 30 | } |
| 31 | // Any other foreign host may be a custom pages domain. | ||
| 32 | if host != siteHost && host != "" { | ||
| 33 | if repo, err := s.st.PageDomainRepo(host); err == nil && repo.Visibility == "public" { | ||
| 34 | if r.Method != http.MethodGet && r.Method != http.MethodHead { | ||
| 35 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||
| 36 | return | ||
| 37 | } | ||
| 38 | s.servePageFile(w, r, repo, strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")) | ||
| 39 | return | ||
| 40 | } | ||
| 41 | } | ||
| 30 | forge.ServeHTTP(w, r) | 42 | forge.ServeHTTP(w, r) |
| 31 | }) | 43 | }) |
| 32 | } | 44 | } |
internal/httpd/routes.go +3 −5
| @@ -122,11 +122,9 @@ func (s *Server) Handler() http.Handler { | |||
| 122 | if len(s.cfg.GoImport) > 0 { | 122 | if len(s.cfg.GoImport) > 0 { |
| 123 | h = s.goImportHandler(mux) | 123 | h = s.goImportHandler(mux) |
| 124 | } | 124 | } |
| 125 | h = s.securityHeaders(h) | 125 | // Always wrapped: custom pages domains work with or without the |
| 126 | if s.cfg.Pages.Domain != "" { | 126 | // built-in [pages] domain. |
| 127 | h = s.pagesRouter(h) | 127 | return s.pagesRouter(s.securityHeaders(h)) |
| 128 | } | ||
| 129 | return h | ||
| 130 | } | 128 | } |
| 131 | 129 | ||
| 132 | // securityHeaders sets defensive response headers on every reply. The CSP | 130 | // securityHeaders sets defensive response headers on every reply. The CSP |
internal/store/migrations/0023_page_domains.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE page_domains; | ||
internal/store/migrations/0023_page_domains.up.sql added +7
| @@ -0,0 +1,7 @@ | |||
| 1 | -- Custom domains for pages: one domain serves one repo's pages branch. | ||
| 2 | CREATE TABLE page_domains ( | ||
| 3 | domain TEXT PRIMARY KEY, | ||
| 4 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 5 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) | ||
| 6 | ); | ||
| 7 | CREATE INDEX page_domains_repo ON page_domains(repo_id); | ||
internal/store/pagedomains.go added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // AddPageDomain claims a domain for a repo's pages site. The primary key | ||
| 9 | // makes claims exclusive instance-wide. | ||
| 10 | func (s *Store) AddPageDomain(domain string, repoID int64) error { | ||
| 11 | _, err := s.DB.Exec("INSERT INTO page_domains (domain, repo_id) VALUES (?, ?)", domain, repoID) | ||
| 12 | if err != nil && isUniqueErr(err) { | ||
| 13 | return ErrExists | ||
| 14 | } | ||
| 15 | return err | ||
| 16 | } | ||
| 17 | |||
| 18 | func (s *Store) RemovePageDomain(domain string, repoID int64) error { | ||
| 19 | res, err := s.DB.Exec("DELETE FROM page_domains WHERE domain = ? AND repo_id = ?", domain, repoID) | ||
| 20 | if err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 24 | return ErrNotFound | ||
| 25 | } | ||
| 26 | return nil | ||
| 27 | } | ||
| 28 | |||
| 29 | func (s *Store) ListPageDomains(repoID int64) ([]string, error) { | ||
| 30 | rows, err := s.DB.Query("SELECT domain FROM page_domains WHERE repo_id = ? ORDER BY domain", repoID) | ||
| 31 | if err != nil { | ||
| 32 | return nil, err | ||
| 33 | } | ||
| 34 | defer rows.Close() | ||
| 35 | var out []string | ||
| 36 | for rows.Next() { | ||
| 37 | var d string | ||
| 38 | if err := rows.Scan(&d); err != nil { | ||
| 39 | return nil, err | ||
| 40 | } | ||
| 41 | out = append(out, d) | ||
| 42 | } | ||
| 43 | return out, rows.Err() | ||
| 44 | } | ||
| 45 | |||
| 46 | // PageDomainRepo resolves a request host to the repo serving it. | ||
| 47 | func (s *Store) PageDomainRepo(domain string) (Repo, error) { | ||
| 48 | var repoID int64 | ||
| 49 | err := s.DB.QueryRow("SELECT repo_id FROM page_domains WHERE domain = ?", domain).Scan(&repoID) | ||
| 50 | if errors.Is(err, sql.ErrNoRows) { | ||
| 51 | return Repo{}, ErrNotFound | ||
| 52 | } | ||
| 53 | if err != nil { | ||
| 54 | return Repo{}, err | ||
| 55 | } | ||
| 56 | return s.RepoByID(repoID) | ||
| 57 | } | ||