internal/control/pagescmd.go
220 lines · 7506 bytes
1package control
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "net"
11 "os"
12 "regexp"
13 "strings"
14 "time"
15
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func init() {
22 register(Command{Path: []string{"repo", "domain", "add"},
23 Summary: "claim a custom pages domain (verify with a DNS TXT record)",
24 Usage: "repo domain add <owner/name> <domain>", Run: runDomainAdd})
25 register(Command{Path: []string{"repo", "domain", "verify"},
26 Summary: "check the DNS challenge and activate a claim",
27 Usage: "repo domain verify <owner/name> <domain>", Run: runDomainVerify})
28 register(Command{Path: []string{"repo", "domain", "remove"},
29 Summary: "remove a custom pages domain",
30 Usage: "repo domain remove <owner/name> <domain>", Run: runDomainRemove})
31 register(Command{Path: []string{"repo", "domain", "list"},
32 Summary: "list custom pages domains",
33 Usage: "repo domain list <owner/name>", ReadOnly: true, Run: runDomainList})
34}
35
36// challengeLabel prefixes the domain for the ownership TXT record.
37const challengeLabel = "_gitbay-challenge."
38
39// hostnamePat is a conservative DNS hostname: dot-separated labels,
40// lowercase, at least two labels.
41var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
42
43// pendingTTL is how long an unverified claim holds a domain. The env
44// override exists for tests.
45func pendingTTL() int {
46 if v := os.Getenv("GITBAY_DOMAIN_PENDING_TTL"); v != "" {
47 if d, err := time.ParseDuration(v); err == nil {
48 return int(d.Seconds())
49 }
50 }
51 return 7 * 24 * 3600
52}
53
54func validatePageDomain(c *Ctx, domain string) error {
55 if !hostnamePat.MatchString(domain) {
56 return fmt.Errorf("invalid domain %q: lowercase hostname like docs.example.org", domain)
57 }
58 if domain == c.Cfg.SiteHost() || strings.HasSuffix(c.Cfg.SiteHost(), "."+domain) {
59 return errors.New("that is the forge's own host: pages content must stay off its origin")
60 }
61 if pd := c.Cfg.Pages.Domain; pd != "" && (domain == pd || strings.HasSuffix(domain, "."+pd)) {
62 return fmt.Errorf("%s is under the built-in pages domain; it is served automatically", domain)
63 }
64 return nil
65}
66
67func challengeRecord(domain, token string) (name, value string) {
68 return challengeLabel + domain, "gitbay-domain-verify=" + token
69}
70
71func runDomainAdd(c *Ctx, args []string) int {
72 if len(args) != 2 {
73 return c.fail(protocol.ExitUsage, "usage: repo domain add <owner/name> <domain>")
74 }
75 domain := strings.ToLower(args[1])
76 if err := validatePageDomain(c, domain); err != nil {
77 return c.failInput(err)
78 }
79 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
80 if code >= 0 {
81 return code
82 }
83 if repo.Visibility != "public" {
84 return c.fail(protocol.ExitUsage, "pages serve public repositories only; %s is private", repo.Path())
85 }
86 buf := make([]byte, 16)
87 rand.Read(buf)
88 token := hex.EncodeToString(buf)
89 if err := c.Store.AddPageDomain(domain, repo.ID, c.User.ID, token, pendingTTL()); err != nil {
90 if errors.Is(err, store.ErrExists) {
91 // Not naming the holder: domain claims must not enumerate repos.
92 return c.fail(protocol.ExitUsage, "%s is already claimed on this instance", domain)
93 }
94 return c.fail(protocol.ExitFailure, "%v", err)
95 }
96 name, value := challengeRecord(domain, token)
97 days := pendingTTL() / 86400
98 return c.emit(map[string]any{
99 "domain": domain, "state": "pending",
100 "challenge_name": name, "challenge_value": value,
101 }, func(w io.Writer) {
102 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",
103 domain, name, value, repo.Path(), domain, days)
104 })
105}
106
107// lookupTXT resolves the challenge record. GITBAY_DNS_SERVER (host:port)
108// overrides the system resolver so tests can answer the challenge.
109func lookupTXT(name string) ([]string, error) {
110 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
111 defer cancel()
112 r := net.DefaultResolver
113 if srv := os.Getenv("GITBAY_DNS_SERVER"); srv != "" {
114 r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
115 var d net.Dialer
116 return d.DialContext(ctx, "udp", srv)
117 }}
118 }
119 return r.LookupTXT(ctx, name)
120}
121
122func runDomainVerify(c *Ctx, args []string) int {
123 if len(args) != 2 {
124 return c.fail(protocol.ExitUsage, "usage: repo domain verify <owner/name> <domain>")
125 }
126 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
127 if code >= 0 {
128 return code
129 }
130 domain := strings.ToLower(args[1])
131 claim, err := c.Store.PageDomainClaim(domain, repo.ID)
132 if err != nil {
133 return c.fail(protocol.ExitNotFound, "%s has no claim on %s", repo.Path(), domain)
134 }
135 if claim.Verified() {
136 return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) {
137 fmt.Fprintf(w, "%s is already verified\n", domain)
138 })
139 }
140 if c.Store.PageDomainExpired(claim, pendingTTL()) {
141 c.Store.RemovePageDomain(domain, repo.ID)
142 return c.fail(protocol.ExitUsage, "the claim on %s expired; run repo domain add again", domain)
143 }
144 name, want := challengeRecord(domain, claim.Token)
145 records, err := lookupTXT(name)
146 if err != nil {
147 return c.fail(protocol.ExitFailure, "looking up %s: %v", name, err)
148 }
149 found := false
150 for _, r := range records {
151 if strings.TrimSpace(r) == want {
152 found = true
153 break
154 }
155 }
156 if !found {
157 return c.fail(protocol.ExitDenied, "%s does not carry the expected record %q", name, want)
158 }
159 if err := c.Store.VerifyPageDomain(domain, repo.ID); err != nil {
160 return c.fail(protocol.ExitFailure, "%v", err)
161 }
162 c.Store.Audit(c.User.ID, "pages.domain_verified", map[string]any{"repo": repo.ID, "domain": domain})
163 return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) {
164 fmt.Fprintf(w, "%s verified — it now serves %s's pages branch; point its A/AAAA records at this server\n", domain, repo.Path())
165 })
166}
167
168func runDomainRemove(c *Ctx, args []string) int {
169 if len(args) != 2 {
170 return c.fail(protocol.ExitUsage, "usage: repo domain remove <owner/name> <domain>")
171 }
172 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
173 if code >= 0 {
174 return code
175 }
176 domain := strings.ToLower(args[1])
177 if err := c.Store.RemovePageDomain(domain, repo.ID); err != nil {
178 if errors.Is(err, store.ErrNotFound) {
179 return c.fail(protocol.ExitNotFound, "%s is not a domain of %s", domain, repo.Path())
180 }
181 return c.fail(protocol.ExitFailure, "%v", err)
182 }
183 return c.emit(map[string]string{"removed": domain}, func(w io.Writer) {
184 fmt.Fprintf(w, "removed %s\n", domain)
185 })
186}
187
188func runDomainList(c *Ctx, args []string) int {
189 if len(args) != 1 {
190 return c.fail(protocol.ExitUsage, "usage: repo domain list <owner/name>")
191 }
192 repo, code := resolveRepo(c, args[0], policy.CanRead)
193 if code >= 0 {
194 return code
195 }
196 ds, err := c.Store.ListPageDomains(repo.ID)
197 if err != nil {
198 return c.fail(protocol.ExitFailure, "%v", err)
199 }
200 type out struct {
201 Domain string `json:"domain"`
202 State string `json:"state"`
203 Verified string `json:"verified_at,omitempty"`
204 }
205 var list []out
206 for _, d := range ds {
207 state := "pending"
208 if d.Verified() {
209 state = "verified"
210 } else if c.Store.PageDomainExpired(d, pendingTTL()) {
211 state = "expired"
212 }
213 list = append(list, out{d.Domain, state, d.VerifiedAt})
214 }
215 return c.emit(list, func(w io.Writer) {
216 for _, d := range list {
217 fmt.Fprintf(w, "%s\t%s\n", d.Domain, d.State)
218 }
219 })
220}