internal/control/pagescmd.go

32a583340a8db2b242ed452180dd239fd118bc26
gitbay/internal/control/pagescmd.go history · blame · raw

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}