internal/control/pagescmd.go
216 lines · 7475 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): 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})
26 register(Command{Path: []string{"repo", "domain", "remove"},
27 Summary: "remove a custom pages domain: repo domain remove <owner/name> <domain>", Run: runDomainRemove})
28 register(Command{Path: []string{"repo", "domain", "list"},
29 Summary: "list custom pages domains: repo domain list <owner/name>", ReadOnly: true, Run: runDomainList})
30}
31
32// challengeLabel prefixes the domain for the ownership TXT record.
33const challengeLabel = "_gitbay-challenge."
34
35// hostnamePat is a conservative DNS hostname: dot-separated labels,
36// lowercase, at least two labels.
37var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
38
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
50func validatePageDomain(c *Ctx, domain string) error {
51 if !hostnamePat.MatchString(domain) {
52 return fmt.Errorf("invalid domain %q: lowercase hostname like docs.example.org", domain)
53 }
54 if domain == c.Cfg.SiteHost() || strings.HasSuffix(c.Cfg.SiteHost(), "."+domain) {
55 return errors.New("that is the forge's own host: pages content must stay off its origin")
56 }
57 if pd := c.Cfg.Pages.Domain; pd != "" && (domain == pd || strings.HasSuffix(domain, "."+pd)) {
58 return fmt.Errorf("%s is under the built-in pages domain; it is served automatically", domain)
59 }
60 return nil
61}
62
63func challengeRecord(domain, token string) (name, value string) {
64 return challengeLabel + domain, "gitbay-domain-verify=" + token
65}
66
67func runDomainAdd(c *Ctx, args []string) int {
68 if len(args) != 2 {
69 return c.fail(protocol.ExitUsage, "usage: repo domain add <owner/name> <domain>")
70 }
71 domain := strings.ToLower(args[1])
72 if err := validatePageDomain(c, domain); err != nil {
73 return c.fail(protocol.ExitUsage, "%v", err)
74 }
75 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
76 if code >= 0 {
77 return code
78 }
79 if repo.Visibility != "public" {
80 return c.fail(protocol.ExitUsage, "pages serve public repositories only; %s is private", repo.Path())
81 }
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 {
86 if errors.Is(err, store.ErrExists) {
87 // Not naming the holder: domain claims must not enumerate repos.
88 return c.fail(protocol.ExitUsage, "%s is already claimed on this instance", domain)
89 }
90 return c.fail(protocol.ExitFailure, "%v", err)
91 }
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())
161 })
162}
163
164func runDomainRemove(c *Ctx, args []string) int {
165 if len(args) != 2 {
166 return c.fail(protocol.ExitUsage, "usage: repo domain remove <owner/name> <domain>")
167 }
168 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
169 if code >= 0 {
170 return code
171 }
172 domain := strings.ToLower(args[1])
173 if err := c.Store.RemovePageDomain(domain, repo.ID); err != nil {
174 if errors.Is(err, store.ErrNotFound) {
175 return c.fail(protocol.ExitNotFound, "%s is not a domain of %s", domain, repo.Path())
176 }
177 return c.fail(protocol.ExitFailure, "%v", err)
178 }
179 return c.emit(map[string]string{"removed": domain}, func(w io.Writer) {
180 fmt.Fprintf(w, "removed %s\n", domain)
181 })
182}
183
184func runDomainList(c *Ctx, args []string) int {
185 if len(args) != 1 {
186 return c.fail(protocol.ExitUsage, "usage: repo domain list <owner/name>")
187 }
188 repo, code := resolveRepo(c, args[0], policy.CanRead)
189 if code >= 0 {
190 return code
191 }
192 ds, err := c.Store.ListPageDomains(repo.ID)
193 if err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
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)
214 }
215 })
216}