main pico / pkg / apps / pico / dns.go
Antonio Mika  ·  2026-10-11
  1package pico
  2
  3import (
  4	"crypto/rand"
  5	"encoding/hex"
  6	"errors"
  7	"fmt"
  8	"net"
  9	"slices"
 10	"strings"
 11
 12	"github.com/picosh/pico/pkg/db"
 13	"github.com/picosh/pico/pkg/shared/router"
 14	"golang.org/x/crypto/ssh"
 15)
 16
 17// customDomainService is a service that serves a domain whose TXT record
 18// names it. check judges the records the way the service does and describes
 19// the result.
 20type customDomainService struct {
 21	space    string
 22	check    func(c *Cmd, records []string) string
 23	wildcard bool
 24}
 25
 26var customDomainServices = []customDomainService{
 27	{space: "pgs", check: siteCheck(resolvePgsSite)},
 28	{space: "prose", check: siteCheck(resolveProseSite)},
 29	// tuns accepts a match under either prefix; _sish is what sish uses.
 30	{space: "tuns", check: checkTunsRecords, wildcard: true},
 31	{space: "sish", check: checkTunsRecords, wildcard: true},
 32}
 33
 34// siteCheck checks a record whose value names a site. Like the services,
 35// it reads only the first record.
 36func siteCheck(resolve func(dbpool db.DB, value string) (*db.User, string, error)) func(c *Cmd, records []string) string {
 37	return func(c *Cmd, records []string) string {
 38		value := strings.TrimSpace(records[0])
 39		user, site, err := resolve(c.Dbpool, value)
 40		if err != nil {
 41			return fmt.Sprintf("%q is invalid: %v", value, err)
 42		}
 43		line := fmt.Sprintf("%q is valid, serving %s", value, site)
 44		if user.ID != c.User.ID {
 45			line += " (not your account)"
 46		}
 47		return line
 48	}
 49}
 50
 51func resolvePgsSite(dbpool db.DB, value string) (*db.User, string, error) {
 52	props, err := router.GetProjectFromSubdomain(value)
 53	if err != nil {
 54		return nil, "", err
 55	}
 56	user, err := dbpool.FindUserByName(props.Username)
 57	if err != nil {
 58		return nil, "", fmt.Errorf("user %q not found", props.Username)
 59	}
 60	if _, err := dbpool.FindProjectByName(user.ID, props.ProjectName); err != nil {
 61		return user, "", fmt.Errorf("user %q has no project %q", user.Name, props.ProjectName)
 62	}
 63	return user, fmt.Sprintf("pgs project %q of %s", props.ProjectName, user.Name), nil
 64}
 65
 66func resolveProseSite(dbpool db.DB, value string) (*db.User, string, error) {
 67	user, err := dbpool.FindUserByName(value)
 68	if err != nil {
 69		return nil, "", fmt.Errorf("user %q not found", value)
 70	}
 71	return user, fmt.Sprintf("prose blog of %s", user.Name), nil
 72}
 73
 74// checkTunsRecords looks for a record holding the user's name or the SHA256
 75// fingerprint of one of their keys. tuns compares the whole record, so it
 76// must match exactly.
 77func checkTunsRecords(c *Cmd, records []string) string {
 78	keys, err := c.Dbpool.FindKeysByUser(c.User)
 79	if err != nil {
 80		return fmt.Sprintf("could not load your keys: %v", err)
 81	}
 82	names := map[string]string{}
 83	for _, k := range keys {
 84		pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
 85		if err != nil {
 86			continue
 87		}
 88		names[ssh.FingerprintSHA256(pk)] = k.Name
 89	}
 90
 91	for _, r := range records {
 92		if r == c.User.Name {
 93			return fmt.Sprintf("%q is valid, tunnels from any of your keys can use this domain", r)
 94		}
 95		if name, ok := names[r]; ok {
 96			return fmt.Sprintf("%q is valid, tunnels from your key %q can use this domain", r, name)
 97		}
 98	}
 99	for _, r := range records {
100		trimmed := strings.TrimSpace(r)
101		if _, ok := names[trimmed]; ok || trimmed == c.User.Name {
102			return fmt.Sprintf("%q is invalid: it has surrounding whitespace, and tuns needs an exact match", r)
103		}
104	}
105	quoted := make([]string, len(records))
106	for i, r := range records {
107		quoted[i] = fmt.Sprintf("%q", r)
108	}
109	return fmt.Sprintf("%s is invalid: no record is your username or the SHA256 fingerprint of one of your keys", strings.Join(quoted, ", "))
110}
111
112// lookupHost resolves host and returns its canonical name, or "" when it has
113// no CNAME.
114func lookupHost(host string) (string, []string, error) {
115	addrs, err := net.LookupHost(host)
116	if err != nil {
117		return "", nil, err
118	}
119	cname, err := net.LookupCNAME(host)
120	if err != nil {
121		return "", addrs, nil
122	}
123	cname = strings.TrimSuffix(cname, ".")
124	if strings.EqualFold(cname, host) {
125		cname = ""
126	}
127	return cname, addrs, nil
128}
129
130// checkPointer reports what host resolves to and which of the service
131// domains it points at. Addresses are compared so a CNAME to a regional
132// name or an apex A record both count.
133func (c *Cmd) checkPointer(host string, lookup func(string) (string, []string, error)) string {
134	cname, addrs, err := lookup(host)
135	var dnsErr *net.DNSError
136	if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
137		return fmt.Sprintf("%s: no CNAME or A record", host)
138	}
139	if err != nil {
140		return fmt.Sprintf("%s: lookup failed: %v", host, err)
141	}
142
143	line := fmt.Sprintf("%s: resolves to %s", host, strings.Join(addrs, ", "))
144	if cname != "" {
145		line = fmt.Sprintf("%s: CNAME %s, resolves to %s", host, cname, strings.Join(addrs, ", "))
146	}
147	matches := []string{}
148	for _, target := range c.DomainTargets {
149		_, targetAddrs, err := lookup(target)
150		if err != nil {
151			continue
152		}
153		if slices.ContainsFunc(addrs, func(a string) bool { return slices.Contains(targetAddrs, a) }) {
154			matches = append(matches, target)
155		}
156	}
157	if len(matches) == 0 {
158		return fmt.Sprintf("%s, which is not %s", line, strings.Join(c.DomainTargets, ", "))
159	}
160	return fmt.Sprintf("%s, which is %s", line, strings.Join(matches, ", "))
161}
162
163// dnsCheck reports what domain points at and, for each service, whether it
164// has a TXT record the service would accept.
165func (c *Cmd) dnsCheck(domain string) error {
166	domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
167	base, wildcard := strings.CutPrefix(domain, "*.")
168	if base == "" || strings.ContainsAny(base, "/:@ *") {
169		return fmt.Errorf("expected a domain name like example.com or *.example.com")
170	}
171	lookup := c.lookupTXT
172	if lookup == nil {
173		lookup = router.LookupTXTRecords
174	}
175
176	host := domain
177	if wildcard {
178		// tuns verifies a wildcard by looking up a random name under it.
179		label := make([]byte, 6)
180		_, _ = rand.Read(label)
181		host = hex.EncodeToString(label) + "." + base
182	}
183
184	resolve := c.lookupHost
185	if resolve == nil {
186		resolve = lookupHost
187	}
188	c.output(c.checkPointer(host, resolve))
189
190	for _, svc := range customDomainServices {
191		name := router.TXTRecordName(host, svc.space)
192		if wildcard && !svc.wildcard {
193			c.output(fmt.Sprintf("%s: wildcard domains are not supported", router.TXTRecordName(domain, svc.space)))
194			continue
195		}
196		records, err := lookup(host, svc.space)
197		switch {
198		case errors.Is(err, router.ErrNoTXTRecord):
199			c.output(fmt.Sprintf("%s: no TXT record", name))
200		case err != nil:
201			c.output(fmt.Sprintf("%s: lookup failed: %v", name, err))
202		default:
203			c.output(fmt.Sprintf("%s: %s", name, svc.check(c, records)))
204		}
205	}
206	c.output("pgs and prose remember a missing record for 30 seconds and a found record for 2 minutes. tuns checks when a tunnel connects.")
207	return nil
208}