internal/control/pagescmd.go

81272b0b804d31bba1ee5e540b69cffc895ef3bf
gitbay/internal/control/pagescmd.go history · blame · raw

220 lines · 7248 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.usage()
 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.usage()
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.usage()
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.usage()
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}