Commit 0d12684707
Verified · cmc ci/build: success
cmd/gitbay/main.go +5
| @@ -258,6 +258,11 @@ func repoCmd() *cobra.Command { | ||
| 258 | 258 | pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}), |
| 259 | 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 | 266 | group("topics", "free-form repository tags", |
| 262 | 267 | pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}), |
| 263 | 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 | 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 | 199 | return fmt.Errorf("host %q not served here", h) |
| 196 | 200 | } |
| 197 | 201 | m := &autocert.Manager{ |
e2e/pages_test.go +52
| @@ -121,4 +121,56 @@ func TestPages(t *testing.T) { | ||
| 121 | 121 | if status, _ := inst.get(t, "/explore"); status != 200 { |
| 122 | 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 | 244 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 245 | 245 | Archived bool `json:"archived,omitempty"` |
| 246 | 246 | Topics []string `json:"topics,omitempty"` |
| 247 | Domains []string `json:"domains,omitempty"` | |
| 247 | 248 | Mirrors []mirrorOut `json:"mirrors,omitempty"` |
| 248 | 249 | } |
| 249 | 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 | 252 | if err != nil { |
| 252 | 253 | return c.fail(protocol.ExitFailure, "%v", err) |
| 253 | 254 | } |
| 255 | domains, _ := c.Store.ListPageDomains(repo.ID) | |
| 254 | 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 | 258 | // Mirror status is admin-only, like repo mirror list. The token never |
| 257 | 259 | // leaves the server. |
| 258 | 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 | 284 | if len(d.ProtectedBranches) > 0 { |
| 283 | 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 | 290 | for _, m := range d.Mirrors { |
| 286 | 291 | status := "ok" |
| 287 | 292 | if m.Pending { |
internal/httpd/pages.go +13 −1
| @@ -21,12 +21,24 @@ const PagesBranch = "refs/heads/pages" | ||
| 21 | 21 | // a separate origin where the forge has no cookies to protect. |
| 22 | 22 | func (s *Server) pagesRouter(forge http.Handler) http.Handler { |
| 23 | 23 | domain := s.cfg.Pages.Domain |
| 24 | siteHost := s.cfg.SiteHost() | |
| 24 | 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | 26 | host := hostOnly(r.Host) |
| 26 | if host == domain || strings.HasSuffix(host, "."+domain) { | |
| 27 | if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) { | |
| 27 | 28 | s.servePage(w, r, host) |
| 28 | 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 | 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 | 122 | if len(s.cfg.GoImport) > 0 { |
| 123 | 123 | h = s.goImportHandler(mux) |
| 124 | 124 | } |
| 125 | h = s.securityHeaders(h) | |
| 126 | if s.cfg.Pages.Domain != "" { | |
| 127 | h = s.pagesRouter(h) | |
| 128 | } | |
| 129 | return h | |
| 125 | // Always wrapped: custom pages domains work with or without the | |
| 126 | // built-in [pages] domain. | |
| 127 | return s.pagesRouter(s.securityHeaders(h)) | |
| 130 | 128 | } |
| 131 | 129 | |
| 132 | 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 | } | |