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