A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 0d12684707

0d12684707ba92a7a5dcf9aee6077df1f8c17c9c

parent: dc0ea39b4b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T17:26:21Z

Custom domains for pages

repo domain add|remove|list maps a domain to a public repo's pages
branch (repo admins; claims are instance-exclusive and the taken-domain
error names no holder). The router serves any mapped foreign host with
the whole path inside the branch; ACME issues certs only for claimed
hosts; the forge host and the built-in pages domain are unclaimable.
repo show lists a repo's pages domains. Migration 0023. Ref #15
cmd/gitbay/main.go +5
@@ -258,6 +258,11 @@ func repoCmd() *cobra.Command {
258258 pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}),
259259 pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
260260 ),
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 ),
261266 group("topics", "free-form repository tags",
262267 pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}),
263268 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 {
192192 return nil
193193 }
194194 }
195 // Custom pages domains: certs only for claimed hosts.
196 if _, err := st.PageDomainRepo(h); err == nil {
197 return nil
198 }
195199 return fmt.Errorf("host %q not served here", h)
196200 }
197201 m := &autocert.Manager{
e2e/pages_test.go +52
@@ -121,4 +121,56 @@ func TestPages(t *testing.T) {
121121 if status, _ := inst.get(t, "/explore"); status != 200 {
122122 t.Fatalf("forge routes broken: %d", status)
123123 }
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 }
124176 }
internal/control/pagescmd.go added +105
@@ -0,0 +1,105 @@
1package control
2
3import (
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
15func 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.
26var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
27
28func 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
41func 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
68func 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
88func 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 {
244244 ProtectedBranches []string `json:"protected_branches,omitempty"`
245245 Archived bool `json:"archived,omitempty"`
246246 Topics []string `json:"topics,omitempty"`
247 Domains []string `json:"domains,omitempty"`
247248 Mirrors []mirrorOut `json:"mirrors,omitempty"`
248249 }
249250 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
@@ -251,8 +252,9 @@ func runRepoShow(c *Ctx, args []string) int {
251252 if err != nil {
252253 return c.fail(protocol.ExitFailure, "%v", err)
253254 }
255 domains, _ := c.Store.ListPageDomains(repo.ID)
254256 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}
256258 // Mirror status is admin-only, like repo mirror list. The token never
257259 // leaves the server.
258260 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 {
282284 if len(d.ProtectedBranches) > 0 {
283285 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
284286 }
287 if len(d.Domains) > 0 {
288 fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
289 }
285290 for _, m := range d.Mirrors {
286291 status := "ok"
287292 if m.Pending {
internal/httpd/pages.go +13 −1
@@ -21,12 +21,24 @@ const PagesBranch = "refs/heads/pages"
2121 // a separate origin where the forge has no cookies to protect.
2222 func (s *Server) pagesRouter(forge http.Handler) http.Handler {
2323 domain := s.cfg.Pages.Domain
24 siteHost := s.cfg.SiteHost()
2425 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2526 host := hostOnly(r.Host)
26 if host == domain || strings.HasSuffix(host, "."+domain) {
27 if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) {
2728 s.servePage(w, r, host)
2829 return
2930 }
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 }
3042 forge.ServeHTTP(w, r)
3143 })
3244 }
internal/httpd/routes.go +3 −5
@@ -122,11 +122,9 @@ func (s *Server) Handler() http.Handler {
122122 if len(s.cfg.GoImport) > 0 {
123123 h = s.goImportHandler(mux)
124124 }
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))
130128 }
131129
132130 // securityHeaders sets defensive response headers on every reply. The CSP
internal/store/migrations/0023_page_domains.down.sql added +1
@@ -0,0 +1 @@
1DROP 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.
2CREATE 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);
7CREATE INDEX page_domains_repo ON page_domains(repo_id);
internal/store/pagedomains.go added +57
@@ -0,0 +1,57 @@
1package store
2
3import (
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.
10func (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
18func (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
29func (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.
47func (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}