main pico / pkg / apps / pico / dns_test.go
Antonio Mika  ·  2026-10-11
  1package pico
  2
  3import (
  4	"bytes"
  5	"crypto/ed25519"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"net"
 10	"strings"
 11	"testing"
 12
 13	"github.com/picosh/pico/pkg/db"
 14	"github.com/picosh/pico/pkg/shared"
 15	"github.com/picosh/pico/pkg/shared/router"
 16	"golang.org/x/crypto/ssh"
 17)
 18
 19type fakeSession struct{ bytes.Buffer }
 20
 21func (s *fakeSession) Exit(int) error        { return nil }
 22func (s *fakeSession) Close() error          { return nil }
 23func (s *fakeSession) Stderr() io.ReadWriter { return &s.Buffer }
 24
 25type dnsCheckDB struct {
 26	db.DB
 27	users    []*db.User
 28	projects map[string]bool
 29	keys     []*db.PublicKey
 30}
 31
 32func (d *dnsCheckDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
 33	keys := []*db.PublicKey{}
 34	for _, k := range d.keys {
 35		if k.UserID == user.ID {
 36			keys = append(keys, k)
 37		}
 38	}
 39	return keys, nil
 40}
 41
 42func (d *dnsCheckDB) FindUserByName(name string) (*db.User, error) {
 43	for _, u := range d.users {
 44		if u.Name == name {
 45			return u, nil
 46		}
 47	}
 48	return nil, fmt.Errorf("user not found")
 49}
 50
 51func (d *dnsCheckDB) FindProjectByName(userID, name string) (*db.Project, error) {
 52	if d.projects[userID+"/"+name] {
 53		return &db.Project{UserID: userID, Name: name}, nil
 54	}
 55	return nil, fmt.Errorf("project not found")
 56}
 57
 58func TestDNSCheck(t *testing.T) {
 59	me := &db.User{ID: "1", Name: "antonio"}
 60	other := &db.User{ID: "2", Name: "erock"}
 61	edKey, _, err := ed25519.GenerateKey(nil)
 62	if err != nil {
 63		t.Fatal(err)
 64	}
 65	pk, err := ssh.NewPublicKey(edKey)
 66	if err != nil {
 67		t.Fatal(err)
 68	}
 69	fingerprint := ssh.FingerprintSHA256(pk)
 70	dbpool := &dnsCheckDB{
 71		users:    []*db.User{me, other},
 72		projects: map[string]bool{"1/blog": true},
 73		keys:     []*db.PublicKey{{UserID: me.ID, Name: "laptop", Key: shared.KeyForKeyText(pk)}},
 74	}
 75
 76	tests := []struct {
 77		name    string
 78		domain  string
 79		records map[string][]string
 80		want    []string
 81	}{
 82		{"no records", "", map[string][]string{}, []string{
 83			"_pgs.example.com: no TXT record",
 84			"_prose.example.com: no TXT record",
 85			"_tuns.example.com: no TXT record",
 86			"_sish.example.com: no TXT record",
 87		}},
 88		{"valid", "", map[string][]string{
 89			"pgs":   {"antonio-blog"},
 90			"prose": {"erock"},
 91			"sish":  {"SHA256:someone-else", fingerprint},
 92		}, []string{
 93			`_pgs.example.com: "antonio-blog" is valid, serving pgs project "blog" of antonio`,
 94			`_prose.example.com: "erock" is valid, serving prose blog of erock (not your account)`,
 95			fmt.Sprintf(`_sish.example.com: %q is valid, tunnels from your key "laptop" can use this domain`, fingerprint),
 96		}},
 97		{"invalid", "", map[string][]string{
 98			"pgs":   {"antonio-blgo"},
 99			"prose": {"nobody"},
100			"sish":  {"SHA256:someone-else"},
101		}, []string{
102			`_pgs.example.com: "antonio-blgo" is invalid: user "antonio" has no project "blgo"`,
103			`_prose.example.com: "nobody" is invalid: user "nobody" not found`,
104			`_sish.example.com: "SHA256:someone-else" is invalid: no record is your username or the SHA256 fingerprint of one of your keys`,
105		}},
106		{"tuns record with whitespace", "", map[string][]string{"sish": {fingerprint + " "}}, []string{
107			fmt.Sprintf(`_sish.example.com: %q is invalid: it has surrounding whitespace, and tuns needs an exact match`, fingerprint+" "),
108		}},
109		{"tuns username", "", map[string][]string{"tuns": {"antonio"}}, []string{
110			`_tuns.example.com: "antonio" is valid, tunnels from any of your keys can use this domain`,
111			"_sish.example.com: no TXT record",
112		}},
113		{"wildcard", "*.example.com", map[string][]string{"pgs": {"antonio-blog"}, "sish": {"antonio"}}, []string{
114			"_pgs.*.example.com: wildcard domains are not supported\r\n",
115			"_prose.*.example.com: wildcard domains are not supported\r\n",
116			`.example.com: "antonio" is valid, tunnels from any of your keys can use this domain`,
117			"_tuns.",
118		}},
119	}
120	for _, tt := range tests {
121		t.Run(tt.name, func(t *testing.T) {
122			session := &fakeSession{}
123			cmd := &Cmd{
124				User:    me,
125				Session: session,
126				Log:     slog.New(slog.DiscardHandler),
127				Dbpool:  dbpool,
128				lookupHost: func(host string) (string, []string, error) {
129					return "", nil, &net.DNSError{IsNotFound: true}
130				},
131				lookupTXT: func(host, space string) ([]string, error) {
132					if tt.domain == "" && host != "example.com" {
133						t.Fatalf("looked up %q", host)
134					}
135					if tt.domain != "" && (!strings.HasSuffix(host, ".example.com") || strings.Contains(host, "*")) {
136						t.Fatalf("looked up %q for %q", host, tt.domain)
137					}
138					if v, ok := tt.records[space]; ok {
139						return v, nil
140					}
141					return nil, router.ErrNoTXTRecord
142				},
143			}
144			domain := tt.domain
145			if domain == "" {
146				domain = " Example.COM. "
147			}
148			if err := cmd.dnsCheck(domain); err != nil {
149				t.Fatal(err)
150			}
151			out := session.String()
152			for _, line := range tt.want {
153				if !strings.Contains(out, line) {
154					t.Errorf("missing %q in output:\n%s", line, out)
155				}
156			}
157		})
158	}
159
160	pointers := []struct {
161		name  string
162		hosts map[string][]string
163		cname string
164		want  string
165	}{
166		{"none", map[string][]string{}, "", "example.com: no CNAME or A record"},
167		{"cname to a region", map[string][]string{
168			"example.com": {"10.0.0.3"},
169			"pgs.sh":      {"10.0.0.1"},
170			"prose.sh":    {"10.0.0.2"},
171			"tuns.sh":     {"10.0.0.3"},
172			"nue.tuns.sh": {"10.0.0.4"},
173		}, "tuns.pico.sh", "example.com: CNAME tuns.pico.sh, resolves to 10.0.0.3, which is tuns.sh"},
174		{"apex A", map[string][]string{
175			"example.com": {"10.0.0.9", "10.0.0.1"},
176			"pgs.sh":      {"10.0.0.1"},
177		}, "", "example.com: resolves to 10.0.0.9, 10.0.0.1, which is pgs.sh"},
178		{"elsewhere", map[string][]string{
179			"example.com": {"10.0.0.9"},
180			"pgs.sh":      {"10.0.0.1"},
181		}, "", "example.com: resolves to 10.0.0.9, which is not pgs.sh, prose.sh, tuns.sh, nue.tuns.sh"},
182	}
183	for _, tt := range pointers {
184		t.Run("pointer "+tt.name, func(t *testing.T) {
185			session := &fakeSession{}
186			cmd := &Cmd{
187				User:          me,
188				Session:       session,
189				Dbpool:        dbpool,
190				DomainTargets: []string{"pgs.sh", "prose.sh", "tuns.sh", "nue.tuns.sh"},
191				lookupHost: func(host string) (string, []string, error) {
192					addrs, ok := tt.hosts[host]
193					if !ok {
194						return "", nil, &net.DNSError{IsNotFound: true}
195					}
196					if host == "example.com" {
197						return tt.cname, addrs, nil
198					}
199					return "", addrs, nil
200				},
201				lookupTXT: func(host, space string) ([]string, error) {
202					return nil, router.ErrNoTXTRecord
203				},
204			}
205			if err := cmd.dnsCheck("example.com"); err != nil {
206				t.Fatal(err)
207			}
208			if out := session.String(); !strings.Contains(out, tt.want+"\r\n") {
209				t.Errorf("missing %q in output:\n%s", tt.want, out)
210			}
211		})
212	}
213
214	cmd := &Cmd{User: me, Session: &fakeSession{}, Dbpool: dbpool}
215	for _, bad := range []string{"https://example.com/", "*.", "a.*.example.com"} {
216		if err := cmd.dnsCheck(bad); err == nil {
217			t.Errorf("%q was accepted as a domain", bad)
218		}
219	}
220}