A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 96f9270d24

96f9270d24de3f5e4ba58638b9f85b87ddd9d3ef

parent: 0d12684707

Verified · cmc ci/build: success

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

DNS-challenge verification for custom pages domains

repo domain add now creates a pending claim owned by the adding user,
with a random token and the TXT record to publish
(_gitbay-challenge.<domain> = gitbay-domain-verify=<token>).
repo domain verify resolves the record and activates the claim; pending
claims hold the name but never serve and never get certs, and expire
after 7 days so abandoned claims free the domain. Verification is
audit-logged. Existing claims are grandfathered verified by migration
0024. repo domain list reports pending/verified/expired; repo show
lists verified domains only. Ref #15
cmd/gitbay/main.go +2 −1
@@ -259,7 +259,8 @@ func repoCmd() *cobra.Command {
259259 pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}),
260260 ),
261261 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}),
262 pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}),
263 pass("verify", "check the DNS challenge and activate a claim: <domain>", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}),
263264 pass("list", "list custom pages domains", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}),
264265 pass("remove", "remove a custom pages domain: <domain>", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}),
265266 ),
e2e/pages_test.go +100 −2
@@ -1,13 +1,17 @@
11 package e2e
22
33 import (
4 "encoding/json"
45 "fmt"
56 "io"
7 "net"
68 "net/http"
79 "os"
810 "path/filepath"
911 "strings"
12 "sync/atomic"
1013 "testing"
14 "time"
1115 )
1216
1317 // pagesGet fetches a path with a pages Host header against the instance.
@@ -29,7 +33,57 @@ func (i *instance) pagesGet(t *testing.T, host, path string) (*http.Response, st
2933 return resp, string(body)
3034 }
3135
36// fakeDNS answers every TXT query with the string in txt (none when empty),
37// standing in for the challenge record during domain verification.
38func fakeDNS(t *testing.T, txt *atomic.Value) string {
39 t.Helper()
40 pc, err := net.ListenPacket("udp", "127.0.0.1:0")
41 if err != nil {
42 t.Fatal(err)
43 }
44 t.Cleanup(func() { pc.Close() })
45 go func() {
46 buf := make([]byte, 512)
47 for {
48 n, addr, err := pc.ReadFrom(buf)
49 if err != nil {
50 return
51 }
52 q := buf[:n]
53 if len(q) < 12 {
54 continue
55 }
56 i := 12
57 for i < len(q) && q[i] != 0 {
58 i += int(q[i]) + 1
59 }
60 i += 5 // name terminator + qtype + qclass
61 if i > len(q) {
62 continue
63 }
64 val, _ := txt.Load().(string)
65 resp := []byte{q[0], q[1], 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0}
66 if val != "" {
67 resp[7] = 1
68 }
69 resp = append(resp, q[12:i]...)
70 if val != "" {
71 resp = append(resp, 0xC0, 0x0C, 0, 16, 0, 1, 0, 0, 0, 60)
72 rdata := append([]byte{byte(len(val))}, val...)
73 resp = append(resp, byte(len(rdata)>>8), byte(len(rdata)))
74 resp = append(resp, rdata...)
75 }
76 pc.WriteTo(resp, addr)
77 }
78 }()
79 return pc.LocalAddr().String()
80}
81
3282 func TestPages(t *testing.T) {
83 var challenge atomic.Value
84 challenge.Store("")
85 t.Setenv("GITBAY_DNS_SERVER", fakeDNS(t, &challenge))
86 t.Setenv("GITBAY_DOMAIN_PENDING_TTL", "5s")
3387 inst := startInstanceWith(t, "[pages]\ndomain = \"p.test\"\n")
3488 aliceKey := inst.newKey(t, "alice")
3589 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
@@ -129,9 +183,37 @@ func TestPages(t *testing.T) {
129183 if _, _, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 4 {
130184 t.Fatal("non-admin claimed a domain")
131185 }
132 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 0 {
186 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org", "--json")
187 if code != 0 {
133188 t.Fatalf("domain add: %s", errOut)
134189 }
190 var addEnv struct {
191 Data struct {
192 ChallengeValue string `json:"challenge_value"`
193 } `json:"data"`
194 }
195 if err := json.Unmarshal([]byte(out), &addEnv); err != nil || addEnv.Data.ChallengeValue == "" {
196 t.Fatalf("no challenge in add output: %s", out)
197 }
198 // Pending claims hold the name but serve nothing.
199 if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") {
200 t.Fatal("pending claim already serves")
201 }
202 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "create", "bob/held"); code != 0 {
203 t.Fatalf("bob repo: %s", errOut)
204 }
205 if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "docs.example.org"); code != 2 {
206 t.Fatal("pending claim did not hold the name")
207 }
208 // Verification: wrong record refused, right record activates.
209 challenge.Store("gitbay-domain-verify=nope")
210 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 4 {
211 t.Fatal("wrong TXT accepted")
212 }
213 challenge.Store(addEnv.Data.ChallengeValue)
214 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 0 {
215 t.Fatalf("verify: %s", errOut)
216 }
135217 // The whole path maps into the repo's pages branch, no /<repo>/ prefix.
136218 resp, body = inst.pagesGet(t, "docs.example.org", "/")
137219 if resp.StatusCode != 200 || !strings.Contains(body, "project site") {
@@ -161,7 +243,7 @@ func TestPages(t *testing.T) {
161243 t.Fatal("private repo got a domain")
162244 }
163245 // repo show lists it; removal stops serving.
164 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/site")
246 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site")
165247 if !strings.Contains(out, "pages domains: docs.example.org") {
166248 t.Fatalf("repo show missing domains:\n%s", out)
167249 }
@@ -173,4 +255,20 @@ func TestPages(t *testing.T) {
173255 if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") {
174256 t.Fatal("removed domain still serves")
175257 }
258
259 // Expired pending claims free the name; live ones hold it.
260 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "exp.example.org"); code != 0 {
261 t.Fatalf("expiry claim: %s", errOut)
262 }
263 if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 2 {
264 t.Fatal("live pending claim did not hold the name")
265 }
266 time.Sleep(5500 * time.Millisecond) // one TTL (GITBAY_DOMAIN_PENDING_TTL=5s) plus slack
267 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 0 {
268 t.Fatalf("expired claim still held the name: %s", errOut)
269 }
270 // The original claimant's expired claim is gone, not resurrectable.
271 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "exp.example.org"); code != 3 {
272 t.Fatal("expired claim still verifiable")
273 }
176274 }
internal/control/pagescmd.go +118 −7
@@ -1,11 +1,17 @@
11 package control
22
33 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
47 "errors"
58 "fmt"
69 "io"
10 "net"
11 "os"
712 "regexp"
813 "strings"
14 "time"
915
1016 "gitbay.org/gitbay/internal/policy"
1117 "gitbay.org/gitbay/internal/protocol"
@@ -14,17 +20,33 @@ import (
1420
1521 func init() {
1622 register(Command{Path: []string{"repo", "domain", "add"},
17 Summary: "serve pages on a custom domain: repo domain add <owner/name> <domain>", Run: runDomainAdd})
23 Summary: "claim a custom pages domain (verify with a DNS TXT record): repo domain add <owner/name> <domain>", Run: runDomainAdd})
24 register(Command{Path: []string{"repo", "domain", "verify"},
25 Summary: "check the DNS challenge and activate a claim: repo domain verify <owner/name> <domain>", Run: runDomainVerify})
1826 register(Command{Path: []string{"repo", "domain", "remove"},
1927 Summary: "remove a custom pages domain: repo domain remove <owner/name> <domain>", Run: runDomainRemove})
2028 register(Command{Path: []string{"repo", "domain", "list"},
2129 Summary: "list custom pages domains: repo domain list <owner/name>", ReadOnly: true, Run: runDomainList})
2230 }
2331
32// challengeLabel prefixes the domain for the ownership TXT record.
33const challengeLabel = "_gitbay-challenge."
34
2435 // hostnamePat is a conservative DNS hostname: dot-separated labels,
2536 // lowercase, at least two labels.
2637 var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
2738
39// pendingTTL is how long an unverified claim holds a domain. The env
40// override exists for tests.
41func pendingTTL() int {
42 if v := os.Getenv("GITBAY_DOMAIN_PENDING_TTL"); v != "" {
43 if d, err := time.ParseDuration(v); err == nil {
44 return int(d.Seconds())
45 }
46 }
47 return 7 * 24 * 3600
48}
49
2850 func validatePageDomain(c *Ctx, domain string) error {
2951 if !hostnamePat.MatchString(domain) {
3052 return fmt.Errorf("invalid domain %q: lowercase hostname like docs.example.org", domain)
@@ -38,6 +60,10 @@ func validatePageDomain(c *Ctx, domain string) error {
3860 return nil
3961 }
4062
63func challengeRecord(domain, token string) (name, value string) {
64 return challengeLabel + domain, "gitbay-domain-verify=" + token
65}
66
4167 func runDomainAdd(c *Ctx, args []string) int {
4268 if len(args) != 2 {
4369 return c.fail(protocol.ExitUsage, "usage: repo domain add <owner/name> <domain>")
@@ -53,15 +79,85 @@ func runDomainAdd(c *Ctx, args []string) int {
5379 if repo.Visibility != "public" {
5480 return c.fail(protocol.ExitUsage, "pages serve public repositories only; %s is private", repo.Path())
5581 }
56 if err := c.Store.AddPageDomain(domain, repo.ID); err != nil {
82 buf := make([]byte, 16)
83 rand.Read(buf)
84 token := hex.EncodeToString(buf)
85 if err := c.Store.AddPageDomain(domain, repo.ID, c.User.ID, token, pendingTTL()); err != nil {
5786 if errors.Is(err, store.ErrExists) {
5887 // Not naming the holder: domain claims must not enumerate repos.
5988 return c.fail(protocol.ExitUsage, "%s is already claimed on this instance", domain)
6089 }
6190 return c.fail(protocol.ExitFailure, "%v", err)
6291 }
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())
92 name, value := challengeRecord(domain, token)
93 days := pendingTTL() / 86400
94 return c.emit(map[string]any{
95 "domain": domain, "state": "pending",
96 "challenge_name": name, "challenge_value": value,
97 }, func(w io.Writer) {
98 fmt.Fprintf(w, "%s claimed, pending ownership proof. Create this DNS record:\n\n %s\tTXT\t%q\n\nthen run: repo domain verify %s %s\nUnverified claims expire after %d days.\n",
99 domain, name, value, repo.Path(), domain, days)
100 })
101}
102
103// lookupTXT resolves the challenge record. GITBAY_DNS_SERVER (host:port)
104// overrides the system resolver so tests can answer the challenge.
105func lookupTXT(name string) ([]string, error) {
106 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
107 defer cancel()
108 r := net.DefaultResolver
109 if srv := os.Getenv("GITBAY_DNS_SERVER"); srv != "" {
110 r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
111 var d net.Dialer
112 return d.DialContext(ctx, "udp", srv)
113 }}
114 }
115 return r.LookupTXT(ctx, name)
116}
117
118func runDomainVerify(c *Ctx, args []string) int {
119 if len(args) != 2 {
120 return c.fail(protocol.ExitUsage, "usage: repo domain verify <owner/name> <domain>")
121 }
122 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
123 if code >= 0 {
124 return code
125 }
126 domain := strings.ToLower(args[1])
127 claim, err := c.Store.PageDomainClaim(domain, repo.ID)
128 if err != nil {
129 return c.fail(protocol.ExitNotFound, "%s has no claim on %s", repo.Path(), domain)
130 }
131 if claim.Verified() {
132 return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) {
133 fmt.Fprintf(w, "%s is already verified\n", domain)
134 })
135 }
136 if c.Store.PageDomainExpired(claim, pendingTTL()) {
137 c.Store.RemovePageDomain(domain, repo.ID)
138 return c.fail(protocol.ExitUsage, "the claim on %s expired; run repo domain add again", domain)
139 }
140 name, want := challengeRecord(domain, claim.Token)
141 records, err := lookupTXT(name)
142 if err != nil {
143 return c.fail(protocol.ExitFailure, "looking up %s: %v", name, err)
144 }
145 found := false
146 for _, r := range records {
147 if strings.TrimSpace(r) == want {
148 found = true
149 break
150 }
151 }
152 if !found {
153 return c.fail(protocol.ExitDenied, "%s does not carry the expected record %q", name, want)
154 }
155 if err := c.Store.VerifyPageDomain(domain, repo.ID); err != nil {
156 return c.fail(protocol.ExitFailure, "%v", err)
157 }
158 c.Store.Audit(c.User.ID, "pages.domain_verified", map[string]any{"repo": repo.ID, "domain": domain})
159 return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) {
160 fmt.Fprintf(w, "%s verified — it now serves %s's pages branch; point its A/AAAA records at this server\n", domain, repo.Path())
65161 })
66162 }
67163
@@ -97,9 +193,24 @@ func runDomainList(c *Ctx, args []string) int {
97193 if err != nil {
98194 return c.fail(protocol.ExitFailure, "%v", err)
99195 }
100 return c.emit(ds, func(w io.Writer) {
101 for _, d := range ds {
102 fmt.Fprintln(w, d)
196 type out struct {
197 Domain string `json:"domain"`
198 State string `json:"state"`
199 Verified string `json:"verified_at,omitempty"`
200 }
201 var list []out
202 for _, d := range ds {
203 state := "pending"
204 if d.Verified() {
205 state = "verified"
206 } else if c.Store.PageDomainExpired(d, pendingTTL()) {
207 state = "expired"
208 }
209 list = append(list, out{d.Domain, state, d.VerifiedAt})
210 }
211 return c.emit(list, func(w io.Writer) {
212 for _, d := range list {
213 fmt.Fprintf(w, "%s\t%s\n", d.Domain, d.State)
103214 }
104215 })
105216 }
internal/control/repo.go +8 −1
@@ -252,7 +252,14 @@ func runRepoShow(c *Ctx, args []string) int {
252252 if err != nil {
253253 return c.fail(protocol.ExitFailure, "%v", err)
254254 }
255 domains, _ := c.Store.ListPageDomains(repo.ID)
255 var domains []string
256 if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
257 for _, pd := range ds {
258 if pd.Verified() {
259 domains = append(domains, pd.Domain)
260 }
261 }
262 }
256263 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
257264 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil}
258265 // Mirror status is admin-only, like repo mirror list. The token never
internal/store/migrations/0024_domain_verify.down.sql added +3
@@ -0,0 +1,3 @@
1ALTER TABLE page_domains DROP COLUMN token;
2ALTER TABLE page_domains DROP COLUMN user_id;
3ALTER TABLE page_domains DROP COLUMN verified_at;
internal/store/migrations/0024_domain_verify.up.sql added +6
@@ -0,0 +1,6 @@
1- DNS-challenge verification for custom pages domains. Existing claims
2- predate verification and stay serving: they are grandfathered verified.
3ALTER TABLE page_domains ADD COLUMN token TEXT NOT NULL DEFAULT '';
4ALTER TABLE page_domains ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
5ALTER TABLE page_domains ADD COLUMN verified_at TEXT NOT NULL DEFAULT '';
6UPDATE page_domains SET verified_at = created_at WHERE verified_at = '';
internal/store/pagedomains.go +78 −11
@@ -5,10 +5,32 @@ import (
55 "errors"
66 )
77
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)
8// PageDomain is one custom-domain claim. A claim starts pending — it holds
9// the domain but serves nothing — and activates when the DNS challenge
10// verifies. Pending claims expire so a squatted claim frees itself.
11type PageDomain struct {
12 Domain string
13 RepoID int64
14 UserID int64
15 Token string
16 CreatedAt string
17 VerifiedAt string
18}
19
20func (d PageDomain) Verified() bool { return d.VerifiedAt != "" }
21
22// AddPageDomain claims a domain for a repo. Expired pending claims (any
23// repo's) are cleared first, so abandonment frees the name; live claims
24// make the insert fail with ErrExists.
25func (s *Store) AddPageDomain(domain string, repoID, userID int64, token string, ttlSeconds int) error {
26 if _, err := s.DB.Exec(
27 "DELETE FROM page_domains WHERE domain = ? AND verified_at = '' AND strftime('%s','now') - strftime('%s', created_at) > ?",
28 domain, ttlSeconds); err != nil {
29 return err
30 }
31 _, err := s.DB.Exec(
32 "INSERT INTO page_domains (domain, repo_id, user_id, token) VALUES (?, ?, ?, ?)",
33 domain, repoID, userID, token)
1234 if err != nil && isUniqueErr(err) {
1335 return ErrExists
1436 }
@@ -26,16 +48,59 @@ func (s *Store) RemovePageDomain(domain string, repoID int64) error {
2648 return nil
2749 }
2850
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)
51const pageDomainSelect = "SELECT domain, repo_id, user_id, token, created_at, verified_at FROM page_domains"
52
53func scanPageDomain(row interface{ Scan(...any) error }) (PageDomain, error) {
54 var d PageDomain
55 err := row.Scan(&d.Domain, &d.RepoID, &d.UserID, &d.Token, &d.CreatedAt, &d.VerifiedAt)
56 return d, err
57}
58
59// PageDomainClaim returns a repo's claim on a domain, verified or pending.
60func (s *Store) PageDomainClaim(domain string, repoID int64) (PageDomain, error) {
61 d, err := scanPageDomain(s.DB.QueryRow(
62 pageDomainSelect+" WHERE domain = ? AND repo_id = ?", domain, repoID))
63 if errors.Is(err, sql.ErrNoRows) {
64 return d, ErrNotFound
65 }
66 return d, err
67}
68
69// PageDomainExpired reports whether a pending claim has outlived the TTL.
70func (s *Store) PageDomainExpired(d PageDomain, ttlSeconds int) bool {
71 if d.Verified() {
72 return false
73 }
74 var expired bool
75 s.DB.QueryRow(
76 "SELECT strftime('%s','now') - strftime('%s', ?) > ?", d.CreatedAt, ttlSeconds).Scan(&expired)
77 return expired
78}
79
80// VerifyPageDomain activates a pending claim.
81func (s *Store) VerifyPageDomain(domain string, repoID int64) error {
82 res, err := s.DB.Exec(
83 "UPDATE page_domains SET verified_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE domain = ? AND repo_id = ?",
84 domain, repoID)
85 if err != nil {
86 return err
87 }
88 if n, _ := res.RowsAffected(); n == 0 {
89 return ErrNotFound
90 }
91 return nil
92}
93
94func (s *Store) ListPageDomains(repoID int64) ([]PageDomain, error) {
95 rows, err := s.DB.Query(pageDomainSelect+" WHERE repo_id = ? ORDER BY domain", repoID)
3196 if err != nil {
3297 return nil, err
3398 }
3499 defer rows.Close()
35 var out []string
100 var out []PageDomain
36101 for rows.Next() {
37 var d string
38 if err := rows.Scan(&d); err != nil {
102 d, err := scanPageDomain(rows)
103 if err != nil {
39104 return nil, err
40105 }
41106 out = append(out, d)
@@ -43,10 +108,12 @@ func (s *Store) ListPageDomains(repoID int64) ([]string, error) {
43108 return out, rows.Err()
44109 }
45110
46// PageDomainRepo resolves a request host to the repo serving it.
111// PageDomainRepo resolves a request host to the repo serving it. Only
112// verified claims serve.
47113 func (s *Store) PageDomainRepo(domain string) (Repo, error) {
48114 var repoID int64
49 err := s.DB.QueryRow("SELECT repo_id FROM page_domains WHERE domain = ?", domain).Scan(&repoID)
115 err := s.DB.QueryRow(
116 "SELECT repo_id FROM page_domains WHERE domain = ? AND verified_at != ''", domain).Scan(&repoID)
50117 if errors.Is(err, sql.ErrNoRows) {
51118 return Repo{}, ErrNotFound
52119 }