internal/control/pagescmd.go

0338e6ace3de199d5fc383852649919b68ef3e42
gitbay/internal/control/pagescmd.go history · blame · raw

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}