Commit a1973f3

Antonio Mika  ·  2026-10-09 10:33:54 -0400 EDT
parent a0ec3bd
feat(pico): add dns-check and speed up pgs serving
26 files changed,  +1134, -161
+1, -0
......@@ -88,6 +88,7 @@ PGS_STORAGE_DIR=.storage
8888 PGS_DEBUG=1
8989 PGS_CACHE_TTL=600s
9090 PGS_CACHE_MAX_ITEMS=0
91+PGS_CACHE_MAX_BODY_SIZE=0
9192
9293 PICO_CADDYFILE=./caddy/Caddyfile.pico
9394 PICO_V4=
+2, -2
......@@ -28,13 +28,13 @@ func main() {
2828 }()
2929 cfg := pgs.NewPgsConfig(logger, nil, nil, drain)
3030 proxy := newProxyServe(cfg.Logger)
31- httpCache := pgs.NewPgsHttpCache(cfg, proxy)
31+ httpCache, cache := pgs.NewPgsHttpCache(cfg, proxy)
3232 cacher := &cachedHttp{
3333 Logger: cfg.Logger,
3434 Cache: httpCache,
3535 }
3636
37- go pgs.CacheMgmt(ctx, cfg.CacheClearingQueue, cfg, httpCache.Cache)
37+ go pgs.CacheMgmt(ctx, cfg, cache, nil)
3838
3939 portStr := fmt.Sprintf(":%s", cfg.WebPort)
4040 cfg.Logger.Info(
+5, -2
......@@ -233,8 +233,11 @@ func calcRoutes(projectName, fp string, userRedirects []*RedirectRule) []*HttpRe
233233 case "wildcard":
234234 fallthrough
235235 case "variable":
236- rr := regexp.MustCompile(fromMatcher)
237- match = rr.FindStringSubmatch(fp)
236+ // The pattern comes from the user's _redirects, so an invalid one
237+ // is a rule that never matches.
238+ if rr, err := regexp.Compile(fromMatcher); err == nil {
239+ match = rr.FindStringSubmatch(fp)
240+ }
238241 case "none":
239242 fallthrough
240243 default:
+21, -1
......@@ -80,6 +80,12 @@ func (c *Cmd) notice() {
8080 }
8181 }
8282
83+// purge clears what the web servers cache about a project after a command
84+// changed it.
85+func (c *Cmd) purge(projectName string) {
86+ c.Cfg.CacheClearingQueue <- getSurrogateKey(c.User.Name, projectName)
87+}
88+
8389 func (c *Cmd) RmProjectAssets(projectName string) error {
8490 bucketName := shared.GetAssetBucketName(c.User.ID)
8591 bucket, err := c.Store.GetBucket(bucketName)
......@@ -98,6 +104,9 @@ func (c *Cmd) RmProjectAssets(projectName string) error {
98104 return nil
99105 }
100106 c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
107+ if c.Write {
108+ defer c.purge(projectName)
109+ }
101110
102111 for _, file := range fileList {
103112 if file.IsDir() {
......@@ -291,6 +300,9 @@ func (c *Cmd) unlink(projectName string) error {
291300 if err != nil {
292301 return err
293302 }
303+ if c.Write {
304+ c.purge(project.Name)
305+ }
294306 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
295307
296308 return nil
......@@ -365,6 +377,9 @@ func (c *Cmd) link(projectName, linkTo string) error {
365377 if err != nil {
366378 return err
367379 }
380+ if c.Write {
381+ c.purge(projectName)
382+ }
368383
369384 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
370385 c.output(out)
......@@ -463,6 +478,7 @@ func (c *Cmd) prune(prefix string, keepNumLatest int) error {
463478 if err != nil {
464479 return err
465480 }
481+ c.purge(project.Name)
466482 }
467483 }
468484
......@@ -500,6 +516,7 @@ func (c *Cmd) rm(projectName string) error {
500516 if err != nil {
501517 return err
502518 }
519+ c.purge(project.Name)
503520 }
504521 } else {
505522 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
......@@ -524,7 +541,10 @@ func (c *Cmd) acl(projectName, aclType string, acls []string) error {
524541 Data: acls,
525542 }
526543 if c.Write {
527- return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
544+ if err := c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl); err != nil {
545+ return err
546+ }
547+ c.purge(projectName)
528548 }
529549 return nil
530550 }
+63, -0
......@@ -0,0 +1,63 @@
1+package pgs
2+
3+import (
4+ "bytes"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/picosh/pico/pkg/shared"
10+ "github.com/picosh/pico/pkg/storage"
11+)
12+
13+type fakeCmdSession struct{ bytes.Buffer }
14+
15+func (s *fakeCmdSession) Exit(int) error { return nil }
16+func (s *fakeCmdSession) Close() error { return nil }
17+func (s *fakeCmdSession) Stderr() io.ReadWriter { return &s.Buffer }
18+
19+// Commands that change how a project is served purge its cached pages and
20+// records.
21+func TestCliPurges(t *testing.T) {
22+ logger := slog.New(slog.DiscardHandler)
23+ dbpool := NewPgsDb(logger)
24+ user := dbpool.Users[0]
25+ if _, err := dbpool.InsertProject(user.ID, "other", "other"); err != nil {
26+ t.Fatal(err)
27+ }
28+ st, err := storage.NewStorageMemory(map[string]map[string]string{
29+ shared.GetAssetBucketName(user.ID): {"/test/index.html": "hello"},
30+ })
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
35+ cmd := &Cmd{User: user, Session: &fakeCmdSession{}, Log: logger, Store: st, Dbpool: dbpool, Write: true, Cfg: cfg}
36+
37+ tests := []struct {
38+ name string
39+ run func() error
40+ }{
41+ {"acl", func() error { return cmd.acl("test", "private", nil) }},
42+ {"link", func() error { return cmd.link("test", "other") }},
43+ {"unlink", func() error { return cmd.unlink("test") }},
44+ {"rm", func() error { return cmd.rm("test") }},
45+ }
46+ want := getSurrogateKey(user.Name, "test")
47+ for _, tt := range tests {
48+ if err := tt.run(); err != nil {
49+ t.Fatalf("%s: %v", tt.name, err)
50+ }
51+ select {
52+ case got := <-cfg.CacheClearingQueue:
53+ if got != want {
54+ t.Fatalf("%s purged %q, want %q", tt.name, got, want)
55+ }
56+ default:
57+ t.Fatalf("%s purged nothing", tt.name)
58+ }
59+ for len(cfg.CacheClearingQueue) > 0 {
60+ <-cfg.CacheClearingQueue
61+ }
62+ }
63+}
+10, -0
......@@ -15,6 +15,7 @@ import (
1515 type PgsConfig struct {
1616 CacheTTL time.Duration
1717 CacheMaxItems int
18+ CacheMaxBodySize int64
1819 Domain string
1920 MaxAssetSize int64
2021 MaxSize uint64
......@@ -65,6 +66,10 @@ func (c *PgsConfig) StaticPath(fname string) string {
6566 var maxSize = uint64(50 * shared.MB)
6667 var maxAssetSize = int64(10 * shared.MB)
6768
69+// Larger responses stream from storage instead of being held in the cache.
70+// Zero means no limit.
71+var cacheMaxBodySize = int64(0)
72+
6873 // Needs to be small for caching files like _headers and _redirects.
6974 var maxSpecialFileSize = int64(5 * shared.KB)
7075
......@@ -81,6 +86,10 @@ func NewPgsConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageSer
8186 if cacheMaxItemsStr != "" {
8287 cacheMaxItems, _ = strconv.Atoi(cacheMaxItemsStr)
8388 }
89+ cacheMaxBody, err := strconv.ParseInt(shared.GetEnv("PGS_CACHE_MAX_BODY_SIZE", ""), 10, 64)
90+ if err != nil {
91+ cacheMaxBody = cacheMaxBodySize
92+ }
8493
8594 sshHost := shared.GetEnv("PGS_SSH_HOST", "0.0.0.0")
8695 sshPort := shared.GetEnv("PGS_SSH_PORT", "2222")
......@@ -88,6 +97,7 @@ func NewPgsConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageSer
8897 cfg := PgsConfig{
8998 CacheTTL: cacheTTL,
9099 CacheMaxItems: cacheMaxItems,
100+ CacheMaxBodySize: cacheMaxBody,
91101 Domain: domain,
92102 MaxAssetSize: maxAssetSize,
93103 MaxSize: maxSize,
+27, -4
......@@ -3,6 +3,7 @@ package pgsdb
33 import (
44 "fmt"
55 "log/slog"
6+ "slices"
67 "time"
78
89 "github.com/google/uuid"
......@@ -149,11 +150,22 @@ func (me *MemoryDB) UpsertProject(userID, projectName, projectDir string) (*db.P
149150 }
150151
151152 func (me *MemoryDB) LinkToProject(userID, projectID, projectDir string, commit bool) error {
152- return errNotImpl
153+ for _, project := range me.Projects {
154+ if project.ID == projectID && project.UserID == userID {
155+ if commit {
156+ project.ProjectDir = projectDir
157+ }
158+ return nil
159+ }
160+ }
161+ return fmt.Errorf("project not found by id %s", projectID)
153162 }
154163
155164 func (me *MemoryDB) RemoveProject(projectID string) error {
156- return errNotImpl
165+ me.Projects = slices.DeleteFunc(me.Projects, func(project *db.Project) bool {
166+ return project.ID == projectID
167+ })
168+ return nil
157169 }
158170
159171 func (me *MemoryDB) FindProjectByName(userID, name string) (*db.Project, error) {
......@@ -172,7 +184,13 @@ func (me *MemoryDB) FindProjectByName(userID, name string) (*db.Project, error)
172184 }
173185
174186 func (me *MemoryDB) FindProjectLinks(userID, name string) ([]*db.Project, error) {
175- return []*db.Project{}, errNotImpl
187+ links := []*db.Project{}
188+ for _, project := range me.Projects {
189+ if project.UserID == userID && project.ProjectDir == name && project.Name != name {
190+ links = append(links, project)
191+ }
192+ }
193+ return links, nil
176194 }
177195
178196 func (me *MemoryDB) FindProjectsByPrefix(userID, prefix string) ([]*db.Project, error) {
......@@ -195,7 +213,12 @@ func (me *MemoryDB) FindProjects(userID string) ([]*db.Project, error) {
195213 }
196214
197215 func (me *MemoryDB) UpdateProjectAcl(userID, name string, acl db.ProjectAcl) error {
198- return errNotImpl
216+ project, err := me.FindProjectByName(userID, name)
217+ if err != nil {
218+ return err
219+ }
220+ project.Acl = acl
221+ return nil
199222 }
200223
201224 func (me *MemoryDB) RegisterAdmin(username, pubkey, pubkeyName string) error {
+7, -11
......@@ -92,24 +92,20 @@ func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
9292 return displayEntries
9393 }
9494
95-func shouldGenerateListing(st storage.StorageServe, bucket storage.Bucket, projectDir string, path string) bool {
95+// dirListing returns the entries to list for a path, and false when the path
96+// is not a directory or has an index.html to serve instead.
97+func dirListing(st storage.StorageServe, bucket storage.Bucket, projectDir string, path string) ([]os.FileInfo, bool) {
9698 dirPath := projectDir + path
97- if path == "/" {
98- dirPath = projectDir + "/"
99- }
10099
101100 entries, err := st.ListObjects(bucket, dirPath, false)
102101 if err != nil || len(entries) == 0 {
103- return false
102+ return nil, false
104103 }
105104
106- indexPath := dirPath + "index.html"
107- obj, _, err := st.GetObject(bucket, indexPath)
108- if err != nil {
109- return true
105+ if _, err := st.StatObject(bucket, dirPath+"index.html"); err == nil {
106+ return nil, false
110107 }
111- _ = obj.Close()
112- return false
108+ return entries, true
113109 }
114110
115111 func generateDirectoryHTML(path string, entries []os.FileInfo) string {
+2, -2
......@@ -211,10 +211,10 @@ func TestShouldGenerateListing(t *testing.T) {
211211 st, _ := storage.NewStorageMemory(fixture.Storage)
212212 bucket := storage.Bucket{Name: "testbucket", Path: "testbucket"}
213213
214- result := shouldGenerateListing(st, bucket, "project", fixture.Path)
214+ _, result := dirListing(st, bucket, "project", fixture.Path)
215215
216216 if result != fixture.Expected {
217- t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
217+ t.Errorf("dirListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
218218 }
219219 })
220220 }
+23, -0
......@@ -2,6 +2,8 @@ package pgs
22
33 import (
44 "fmt"
5+ "log/slog"
6+ "regexp"
57 "slices"
68 "strings"
79 )
......@@ -82,6 +84,27 @@ func parseHeaderText(text string) ([]*HeaderRule, error) {
8284 return rules, nil
8385 }
8486
87+// headerMatcher is a _headers rule with its path compiled.
88+type headerMatcher struct {
89+ path *regexp.Regexp
90+ headers []*HeaderLine
91+}
92+
93+// compileHeaderRules compiles each rule's path once, when the rules are
94+// cached, and drops rules whose path is not a valid pattern.
95+func compileHeaderRules(rules []*HeaderRule, logger *slog.Logger) []headerMatcher {
96+ matchers := make([]headerMatcher, 0, len(rules))
97+ for _, rule := range rules {
98+ re, err := regexp.Compile(rule.Path)
99+ if err != nil {
100+ logger.Error("skipping _headers rule with invalid path", "path", rule.Path, "err", err)
101+ continue
102+ }
103+ matchers = append(matchers, headerMatcher{path: re, headers: rule.Headers})
104+ }
105+ return matchers
106+}
107+
85108 func parseLine(line string) (*HeaderLine, error) {
86109 rule := &HeaderLine{}
87110
+82, -58
......@@ -109,10 +109,10 @@ func (p *PromCacheMetrics) AddUpstreamRequest() {
109109 p.UpstreamReq.Add(1)
110110 }
111111
112-func NewPgsHttpCache(cfg *PgsConfig, upstream http.Handler) *httpcache.HttpCache {
112+func NewPgsHttpCache(cfg *PgsConfig, upstream http.Handler) (*httpcache.HttpCache, *SiteCache) {
113113 ttl := cfg.CacheTTL
114114 metrics := NewPromCacheMetrics(cfg.Logger, prometheus.DefaultRegisterer)
115- cache := expirable.NewLRU(cfg.CacheMaxItems, metrics.EvictCacheItem, ttl)
115+ cache := NewSiteCache(cfg.CacheMaxItems, ttl, metrics.EvictCacheItem)
116116 httpCache := &httpcache.HttpCache{
117117 Ttl: ttl,
118118 Logger: cfg.Logger,
......@@ -123,22 +123,24 @@ func NewPgsHttpCache(cfg *PgsConfig, upstream http.Handler) *httpcache.HttpCache
123123 TxtPrefix: cfg.TxtPrefix,
124124 },
125125 CacheMetrics: metrics,
126+ MaxBodySize: cfg.CacheMaxBodySize,
126127 }
127128 httpCache.Logger.Info(
128129 "httpcache initiated",
129130 "storageType", "expirable.LRU",
130131 "ttl", ttl,
131132 "maxItems", cfg.CacheMaxItems,
133+ "maxBodySize", cfg.CacheMaxBodySize,
132134 )
133- return httpCache
135+ return httpCache, cache
134136 }
135137
136138 func StartApiServer(cfg *PgsConfig) {
137139 ctx := context.Background()
138140
139141 router := NewWebRouter(cfg)
140- httpCache := NewPgsHttpCache(router.Cfg, router)
141- go CacheMgmt(ctx, cfg.CacheClearingQueue, cfg, httpCache.Cache)
142+ httpCache, cache := NewPgsHttpCache(router.Cfg, router)
143+ go CacheMgmt(ctx, cfg, cache, router.clearSiteCache)
142144
143145 portStr := fmt.Sprintf(":%s", cfg.WebPort)
144146 cfg.Logger.Info(
......@@ -160,20 +162,32 @@ type WebRouter struct {
160162 RootRouter *http.ServeMux
161163 UserRouter *http.ServeMux
162164 RedirectsCache *expirable.LRU[string, []*RedirectRule]
163- HeadersCache *expirable.LRU[string, []*HeaderRule]
165+ HeadersCache *expirable.LRU[string, []headerMatcher]
166+ SiteCache *expirable.LRU[string, *siteInfo]
167+}
168+
169+// siteInfo is what serving a site needs from the database and storage.
170+type siteInfo struct {
171+ user *db.User
172+ project *db.Project
173+ bucket storage.Bucket
174+ plus *db.FeatureFlag
175+}
176+
177+func (s *siteInfo) hasPicoPlus() bool {
178+ return s.plus != nil && s.plus.ExpiresAt.After(time.Now())
164179 }
165180
166181 func NewWebRouter(cfg *PgsConfig) *WebRouter {
167- router := newWebRouter(cfg)
168- go router.WatchCacheClear()
169- return router
182+ return newWebRouter(cfg)
170183 }
171184
172185 func newWebRouter(cfg *PgsConfig) *WebRouter {
173186 router := &WebRouter{
174187 Cfg: cfg,
175188 RedirectsCache: expirable.NewLRU[string, []*RedirectRule](2048, nil, shared.CacheTimeout),
176- HeadersCache: expirable.NewLRU[string, []*HeaderRule](2048, nil, shared.CacheTimeout),
189+ HeadersCache: expirable.NewLRU[string, []headerMatcher](2048, nil, shared.CacheTimeout),
190+ SiteCache: expirable.NewLRU[string, *siteInfo](2048, nil, shared.CacheTimeout),
177191 }
178192 router.initRouters()
179193 return router
......@@ -199,14 +213,51 @@ func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
199213 mux.ServeHTTP(w, r.WithContext(ctx))
200214 }
201215
202-func (web *WebRouter) WatchCacheClear() {
203- for key := range web.Cfg.CacheClearingQueue {
204- web.Cfg.Logger.Info("lru cache clear request", "key", key)
205- rKey := filepath.Join(key, "_redirects")
206- web.RedirectsCache.Remove(rKey)
207- hKey := filepath.Join(key, "_headers")
208- web.HeadersCache.Remove(hKey)
216+// clearSiteCache drops what is cached about a site: its database records and
217+// its parsed _redirects and _headers. The key is the surrogate key that
218+// uploads and the CLI purge, or "*" for every site.
219+func (web *WebRouter) clearSiteCache(key string) {
220+ web.Cfg.Logger.Info("lru cache clear request", "key", key)
221+ if key == "*" {
222+ web.SiteCache.Purge()
223+ web.RedirectsCache.Purge()
224+ web.HeadersCache.Purge()
225+ return
209226 }
227+ web.SiteCache.Remove(key)
228+ web.RedirectsCache.Remove(filepath.Join(key, "_redirects"))
229+ web.HeadersCache.Remove(filepath.Join(key, "_headers"))
230+}
231+
232+// findSite looks up the user, project, bucket and pico+ flag behind a
233+// subdomain. Only complete results are cached, so a site that is created or
234+// fixed later is found on its next request.
235+func (web *WebRouter) findSite(props *router.SubdomainProps, logger *slog.Logger) (*siteInfo, int, string) {
236+ key := getSurrogateKey(props.Username, props.ProjectName)
237+ if site, ok := web.SiteCache.Get(key); ok {
238+ return site, http.StatusOK, ""
239+ }
240+
241+ user, err := web.Cfg.DB.FindUserByName(props.Username)
242+ if err != nil {
243+ logger.Info("user not found")
244+ return nil, http.StatusNotFound, "user not found"
245+ }
246+ project, err := web.Cfg.DB.FindProjectByName(user.ID, props.ProjectName)
247+ if err != nil {
248+ logger.Info("project not found")
249+ return nil, http.StatusNotFound, "project not found"
250+ }
251+ bucket, err := web.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
252+ if err != nil {
253+ logger.Error("bucket not found", "err", err)
254+ return nil, http.StatusNotFound, "bucket not found"
255+ }
256+ plus, _ := web.Cfg.DB.FindFeature(user.ID, "plus")
257+
258+ site := &siteInfo{user: user, project: project, bucket: bucket, plus: plus}
259+ web.SiteCache.Add(key, site)
260+ return site, http.StatusOK, ""
210261 }
211262
212263 func (web *WebRouter) initRouters() {
......@@ -376,7 +427,10 @@ func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
376427 w.WriteHeader(http.StatusNotFound)
377428 }
378429
379-func CacheMgmt(ctx context.Context, notify chan string, cfg *PgsConfig, cacher httpcache.Cacher) {
430+// CacheMgmt applies the purges published on the cache-drain pipe. onPurge,
431+// when set, clears whatever else is cached about the site; it runs first so
432+// the http cache isn't refilled from stale records.
433+func CacheMgmt(ctx context.Context, cfg *PgsConfig, cache *SiteCache, onPurge func(site string)) {
380434 cfg.Logger.Info("cache mgmt initiated")
381435 for {
382436 scanner := bufio.NewScanner(cfg.Pubsub)
......@@ -384,20 +438,16 @@ func CacheMgmt(ctx context.Context, notify chan string, cfg *PgsConfig, cacher h
384438 for scanner.Scan() {
385439 subdomain := strings.TrimSpace(scanner.Text())
386440 cfg.Logger.Info("received cache-drain item", "subdomain", subdomain)
387- notify <- subdomain
441+ if onPurge != nil {
442+ onPurge(subdomain)
443+ }
388444
389445 if subdomain == "*" {
390- cacher.Purge()
446+ cache.Purge()
391447 cfg.Logger.Info("successfully cleared cache from remote cli request")
392448 continue
393449 }
394-
395- for _, key := range cacher.Keys() {
396- if strings.HasPrefix(key, subdomain) {
397- cfg.Logger.Info("deleting cache item", "subdomain", subdomain, "key", key)
398- _ = cacher.Remove(key)
399- }
400- }
450+ cache.PurgeSite(subdomain)
401451 }
402452 }
403453 }
......@@ -533,27 +583,15 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
533583 "user", props.Username,
534584 )
535585
536- user, err := web.Cfg.DB.FindUserByName(props.Username)
537- if err != nil {
538- logger.Info("user not found")
539- http.Error(w, "user not found", http.StatusNotFound)
586+ site, status, msg := web.findSite(props, logger)
587+ if site == nil {
588+ http.Error(w, msg, status)
540589 return
541590 }
591+ user, project, bucket := site.user, site.project, site.bucket
542592
543593 logger = logger.With(
544594 "userId", user.ID,
545- )
546-
547- var bucket storage.Bucket
548- bucket, err = web.Cfg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
549- project, perr := web.Cfg.DB.FindProjectByName(user.ID, props.ProjectName)
550- if perr != nil {
551- logger.Info("project not found")
552- http.Error(w, "project not found", http.StatusNotFound)
553- return
554- }
555-
556- logger = logger.With(
557595 "projectId", project.ID,
558596 "project", project.Name,
559597 )
......@@ -589,20 +627,6 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
589627 return
590628 }
591629
592- if err != nil {
593- logger.Error("bucket not found", "err", err)
594- http.Error(w, "bucket not found", http.StatusNotFound)
595- return
596- }
597-
598- hasPicoPlus := false
599- ff, _ := web.Cfg.DB.FindFeature(user.ID, "plus")
600- if ff != nil {
601- if ff.ExpiresAt.After(time.Now()) {
602- hasPicoPlus = true
603- }
604- }
605-
606630 asset := &ApiAssetHandler{
607631 WebRouter: web,
608632 Logger: logger,
......@@ -615,7 +639,7 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
615639 Filepath: fname,
616640 Bucket: bucket,
617641 ImgProcessOpts: opts,
618- HasPicoPlus: hasPicoPlus,
642+ HasPicoPlus: site.hasPicoPlus(),
619643 HttpPass: project.Acl.Type == "http-pass",
620644 }
621645
+19, -27
......@@ -7,7 +7,6 @@ import (
77 "net/http"
88 "net/url"
99 "path/filepath"
10- "regexp"
1110 "strconv"
1211 "strings"
1312
......@@ -42,7 +41,7 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4241 logger := h.Logger
4342 var redirects []*RedirectRule
4443
45- redirectsCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_redirects")
44+ redirectsCacheKey := filepath.Join(getSurrogateKey(h.Username, h.ProjectDir), "_redirects")
4645 logger.Info("looking for _redirects in lru cache", "key", redirectsCacheKey)
4746 if cachedRedirects, found := h.RedirectsCache.Get(redirectsCacheKey); found {
4847 logger.Info("_redirects found in lru cache", "key", redirectsCacheKey)
......@@ -104,11 +103,9 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104103 // before redirecting, this saves a hop that will just end up a 404
105104 if !hasProtocol(fp.Filepath) && strings.HasSuffix(fp.Filepath, "/") {
106105 next := filepath.Join(h.ProjectDir, fp.Filepath, "index.html")
107- obj, _, err := h.Cfg.Storage.GetObject(h.Bucket, next)
108- if err != nil {
106+ if _, err := h.Cfg.Storage.StatObject(h.Bucket, next); err != nil {
109107 continue
110108 }
111- _ = obj.Close()
112109 }
113110 logger.Info(
114111 "redirecting request",
......@@ -177,25 +174,21 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
177174 }
178175
179176 if assetFilepath == "" {
180- if shouldGenerateListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath) {
177+ if entries, ok := dirListing(h.Cfg.Storage, h.Bucket, h.ProjectDir, "/"+fpath); ok {
181178 logger.Info(
182179 "generating directory listing",
183180 "path", fpath,
184181 )
185- dirPath := h.ProjectDir + "/" + fpath
186- entries, err := h.Cfg.Storage.ListObjects(h.Bucket, dirPath, false)
187- if err == nil {
188- requestPath := "/" + fpath
189- if !strings.HasSuffix(requestPath, "/") {
190- requestPath += "/"
191- }
192-
193- html := generateDirectoryHTML(requestPath, entries)
194- w.Header().Set("content-type", "text/html")
195- w.WriteHeader(http.StatusOK)
196- _, _ = w.Write([]byte(html))
197- return
182+ requestPath := "/" + fpath
183+ if !strings.HasSuffix(requestPath, "/") {
184+ requestPath += "/"
198185 }
186+
187+ html := generateDirectoryHTML(requestPath, entries)
188+ w.Header().Set("content-type", "text/html")
189+ w.WriteHeader(http.StatusOK)
190+ _, _ = w.Write([]byte(html))
191+ return
199192 }
200193
201194 logger.Info(
......@@ -210,9 +203,9 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
210203 _ = contents.Close()
211204 }()
212205
213- var headers []*HeaderRule
206+ var headers []headerMatcher
214207
215- headersCacheKey := filepath.Join(getSurrogateKey(h.UserID, h.ProjectDir), "_headers")
208+ headersCacheKey := filepath.Join(getSurrogateKey(h.Username, h.ProjectDir), "_headers")
216209 logger.Info("looking for _headers in lru cache", "key", headersCacheKey)
217210 if cachedHeaders, found := h.HeadersCache.Get(headersCacheKey); found {
218211 logger.Info("_headers found in lru", "key", headersCacheKey)
......@@ -238,21 +231,20 @@ func (h *ApiAssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
238231 return
239232 }
240233
241- headers, err = parseHeaderText(buf.String())
234+ rules, err := parseHeaderText(buf.String())
242235 if err != nil {
243236 logger.Error("could not parse header text", "err", err.Error())
244237 }
238+ headers = compileHeaderRules(rules, logger)
245239 }
246240
247241 h.HeadersCache.Add(headersCacheKey, headers)
248242 }
249243
250244 userHeaders := []*HeaderLine{}
251- for _, headerRule := range headers {
252- rr := regexp.MustCompile(headerRule.Path)
253- match := rr.FindStringSubmatch(assetFilepath)
254- if len(match) > 0 {
255- userHeaders = headerRule.Headers
245+ for _, rule := range headers {
246+ if rule.path.MatchString(assetFilepath) {
247+ userHeaders = rule.headers
256248 }
257249 }
258250
+64, -1
......@@ -5,8 +5,11 @@ import (
55 "fmt"
66 "io"
77 "log/slog"
8+ "strings"
9+ "sync"
810 "time"
911
12+ "github.com/hashicorp/golang-lru/v2/expirable"
1013 "github.com/picosh/pico/pkg/shared"
1114 "github.com/picosh/utils/pipe"
1215 )
......@@ -104,7 +107,6 @@ func CreateSubCacheDrain(ctx context.Context, logger *slog.Logger) *pipe.Reconne
104107 // from the cache).
105108 func purgeCache(cfg *PgsConfig, writer io.Writer, surrogate string) error {
106109 cfg.Logger.Info("purging cache", "surrogate", surrogate)
107- time.Sleep(1 * time.Second)
108110 _, err := writer.Write([]byte(surrogate + "\n"))
109111 return err
110112 }
......@@ -112,3 +114,64 @@ func purgeCache(cfg *PgsConfig, writer io.Writer, surrogate string) error {
112114 func purgeAllCache(cfg *PgsConfig, writer io.Writer) error {
113115 return purgeCache(cfg, writer, "*")
114116 }
117+
118+// SiteCache holds the http cache's responses and indexes their keys by site,
119+// so purging one site doesn't scan the whole cache.
120+type SiteCache struct {
121+ *expirable.LRU[string, []byte]
122+ mu sync.Mutex
123+ sites map[string]map[string]struct{}
124+}
125+
126+func NewSiteCache(size int, ttl time.Duration, onEvict func(key string, value []byte)) *SiteCache {
127+ c := &SiteCache{sites: map[string]map[string]struct{}{}}
128+ c.LRU = expirable.NewLRU(size, func(key string, value []byte) {
129+ c.unindex(key)
130+ if onEvict != nil {
131+ onEvict(key, value)
132+ }
133+ }, ttl)
134+ return c
135+}
136+
137+// cacheKeySite returns the site a PgsCacheKey belongs to.
138+func cacheKeySite(key string) string {
139+ site, _, _ := strings.Cut(key, "__")
140+ return site
141+}
142+
143+func (c *SiteCache) Add(key string, value []byte) bool {
144+ evicted := c.LRU.Add(key, value)
145+ site := cacheKeySite(key)
146+ c.mu.Lock()
147+ defer c.mu.Unlock()
148+ if c.sites[site] == nil {
149+ c.sites[site] = map[string]struct{}{}
150+ }
151+ c.sites[site][key] = struct{}{}
152+ return evicted
153+}
154+
155+// PurgeSite removes every cached response for a site. The index lock is
156+// released before touching the LRU, whose eviction callback takes it.
157+func (c *SiteCache) PurgeSite(site string) {
158+ c.mu.Lock()
159+ keys := c.sites[site]
160+ delete(c.sites, site)
161+ c.mu.Unlock()
162+ for key := range keys {
163+ c.Remove(key)
164+ }
165+}
166+
167+func (c *SiteCache) unindex(key string) {
168+ site := cacheKeySite(key)
169+ c.mu.Lock()
170+ defer c.mu.Unlock()
171+ if keys, ok := c.sites[site]; ok {
172+ delete(keys, key)
173+ if len(keys) == 0 {
174+ delete(c.sites, site)
175+ }
176+ }
177+}
+42, -0
......@@ -0,0 +1,42 @@
1+package pgs
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+)
8+
9+func TestSiteCachePurgeSite(t *testing.T) {
10+ evicted := []string{}
11+ c := NewSiteCache(0, time.Minute, func(key string, _ []byte) { evicted = append(evicted, key) })
12+ keys := []string{
13+ "antonio-blog__GET__/",
14+ "antonio-blog__GET__/about",
15+ "antonio-blog2__GET__/",
16+ "antonio-blog-v2__GET__/",
17+ }
18+ for _, key := range keys {
19+ c.Add(key, []byte("x"))
20+ }
21+
22+ c.PurgeSite("antonio-blog")
23+ got := c.Keys()
24+ slices.Sort(got)
25+ want := []string{"antonio-blog-v2__GET__/", "antonio-blog2__GET__/"}
26+ if !slices.Equal(got, want) {
27+ t.Fatalf("after purge got %q, want %q", got, want)
28+ }
29+ if len(evicted) != 2 {
30+ t.Fatalf("eviction callback ran %d times, want 2", len(evicted))
31+ }
32+
33+ // Entries that leave the cache on their own leave the index too.
34+ c.Remove("antonio-blog2__GET__/")
35+ if _, ok := c.sites["antonio-blog2"]; ok {
36+ t.Fatal("removed entry still indexed")
37+ }
38+ c.Purge()
39+ if len(c.sites) != 0 {
40+ t.Fatalf("index holds %d sites after a full purge", len(c.sites))
41+ }
42+}
+132, -0
......@@ -6,6 +6,7 @@ import (
66 "net/http"
77 "net/http/httptest"
88 "strings"
9+ "sync/atomic"
910 "testing"
1011 "time"
1112
......@@ -413,6 +414,34 @@ func TestApiBasic(t *testing.T) {
413414 },
414415 },
415416 },
417+ {
418+ name: "headers-invalid-path-skipped",
419+ path: "/test.html",
420+ want: "hello world!",
421+ status: http.StatusOK,
422+ contentType: "text/html",
423+ wantCacheCtrl: "public, max-age=31536000, immutable",
424+
425+ storage: map[string]map[string]string{
426+ bucketName: {
427+ "/test/test.html": "hello world!",
428+ "/test/_headers": "/[\n\tx-broken: yes\n/*\n\tcache-control: public, max-age=31536000, immutable",
429+ },
430+ },
431+ },
432+ {
433+ name: "redirects-invalid-pattern-skipped",
434+ path: "/foo(",
435+ want: "404 not found",
436+ status: http.StatusNotFound,
437+ contentType: "text/plain; charset=utf-8",
438+
439+ storage: map[string]map[string]string{
440+ bucketName: {
441+ "/test/_redirects": "/foo( /index.html 301",
442+ },
443+ },
444+ },
416445 }
417446
418447 for _, tc := range tt {
......@@ -748,3 +777,106 @@ func TestDirectoryListing(t *testing.T) {
748777 // })
749778 // }
750779 // }
780+
781+// The purge an upload sends must drop the site's parsed _redirects, or the
782+// old rules keep being served and cached.
783+func TestRulesCacheClearedByUploadPurge(t *testing.T) {
784+ logger := slog.New(slog.DiscardHandler)
785+ dbpool := NewPgsDb(logger)
786+ user := dbpool.Users[0]
787+ bucketName := shared.GetAssetBucketName(user.ID)
788+ st, err := storage.NewStorageMemory(map[string]map[string]string{
789+ bucketName: {
790+ "/test/old.html": "old",
791+ "/test/new.html": "new",
792+ "/test/_redirects": "/page /old.html 200",
793+ },
794+ })
795+ if err != nil {
796+ t.Fatal(err)
797+ }
798+ cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
799+ cfg.Domain = "pgs.test"
800+ router := newWebRouter(cfg)
801+
802+ get := func() string {
803+ rec := httptest.NewRecorder()
804+ router.ServeHTTP(rec, httptest.NewRequest("GET", dbpool.mkpath("/page"), nil))
805+ return strings.TrimSpace(rec.Body.String())
806+ }
807+ if got := get(); got != "old" {
808+ t.Fatalf("before upload got %q", got)
809+ }
810+
811+ bucket, err := st.GetBucket(bucketName)
812+ if err != nil {
813+ t.Fatal(err)
814+ }
815+ if _, _, err := st.PutObject(bucket, "/test/_redirects", strings.NewReader("/page /new.html 200"), &storage.ObjectInfo{}); err != nil {
816+ t.Fatal(err)
817+ }
818+ router.clearSiteCache(getSurrogateKey(user.Name, "test"))
819+ if got := get(); got != "new" {
820+ t.Fatalf("after upload got %q", got)
821+ }
822+}
823+
824+// countingDB counts the lookups serving a site makes.
825+type countingDB struct {
826+ *PgsDb
827+ lookups atomic.Int64
828+}
829+
830+func (c *countingDB) FindUserByName(name string) (*db.User, error) {
831+ c.lookups.Add(1)
832+ return c.PgsDb.FindUserByName(name)
833+}
834+
835+func (c *countingDB) FindProjectByName(userID, name string) (*db.Project, error) {
836+ c.lookups.Add(1)
837+ return c.PgsDb.FindProjectByName(userID, name)
838+}
839+
840+func (c *countingDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
841+ c.lookups.Add(1)
842+ return c.PgsDb.FindFeature(userID, name)
843+}
844+
845+// A site's database records are looked up once and again only after a purge.
846+func TestSiteCache(t *testing.T) {
847+ logger := slog.New(slog.DiscardHandler)
848+ dbpool := &countingDB{PgsDb: NewPgsDb(logger)}
849+ user := dbpool.Users[0]
850+ st, err := storage.NewStorageMemory(map[string]map[string]string{
851+ shared.GetAssetBucketName(user.ID): {"/test/index.html": "hello"},
852+ })
853+ if err != nil {
854+ t.Fatal(err)
855+ }
856+ cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
857+ cfg.Domain = "pgs.test"
858+ router := newWebRouter(cfg)
859+
860+ get := func() {
861+ t.Helper()
862+ rec := httptest.NewRecorder()
863+ router.ServeHTTP(rec, httptest.NewRequest("GET", dbpool.mkpath("/"), nil))
864+ if rec.Code != http.StatusOK {
865+ t.Fatalf("got status %d", rec.Code)
866+ }
867+ }
868+ get()
869+ first := dbpool.lookups.Load()
870+ if first == 0 {
871+ t.Fatal("serving made no lookups")
872+ }
873+ get()
874+ if n := dbpool.lookups.Load(); n != first {
875+ t.Fatalf("second request made %d more lookups", n-first)
876+ }
877+ router.clearSiteCache(getSurrogateKey(user.Name, "test"))
878+ get()
879+ if n := dbpool.lookups.Load(); n != 2*first {
880+ t.Fatalf("request after purge made %d lookups, want %d", n-first, first)
881+ }
882+}
+20, -5
......@@ -42,6 +42,8 @@ type Cmd struct {
4242 Log *slog.Logger
4343 Dbpool db.DB
4444 Write bool
45+
46+ lookupTXT func(host, space string) ([]string, error)
4547 }
4648
4749 func (c *Cmd) output(out string) {
......@@ -49,13 +51,14 @@ func (c *Cmd) output(out string) {
4951 }
5052
5153 func (c *Cmd) help() {
52- helpStr := "Commands: [help, user, logs, access_logs, chat, not-found]\n"
54+ helpStr := "Commands: [help, user, logs, access-logs, chat, not-found, dns-check]\n"
5355 helpStr += "help - this message\n"
5456 helpStr += "user - display user information (returns name, id, account created, pico+ expiration)\n"
5557 helpStr += "logs - stream user logs\n"
56- helpStr += "access_logs - fetch access logs from the last 30 days\n"
58+ helpStr += "access-logs - fetch access logs from the last 30 days\n"
5759 helpStr += "chat - IRC chat (must enable pty with `-t` to the SSH command)\n"
5860 helpStr += "not-found - return all status 404 requests for a host (hostname.com [year|month])\n"
61+ helpStr += "dns-check - check a domain's TXT records for pgs, prose and tuns custom domains (hostname.com or *.hostname.com)\n"
5962 c.output(helpStr)
6063 }
6164
......@@ -96,7 +99,7 @@ func (c *Cmd) notFound(host, interval string) error {
9699 return nil
97100 }
98101
99-func (c *Cmd) access_logs(_ context.Context) error {
102+func (c *Cmd) accessLogs(_ context.Context) error {
100103 fromDate := time.Now().AddDate(0, 0, -30)
101104 logs, err := c.Dbpool.FindAccessLogs(c.User.ID, &fromDate)
102105 if err != nil {
......@@ -264,8 +267,12 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
264267 sesh.Fatal(err)
265268 }
266269 return nil
267- case "access_logs":
268- err = opts.access_logs(sesh.Context())
270+ case "dns-check":
271+ sesh.Fatal(fmt.Errorf("must provide a domain name (`dns-check example.com`)"))
272+ return nil
273+ // access_logs is the command's old name.
274+ case "access-logs", "access_logs":
275+ err = opts.accessLogs(sesh.Context())
269276 if err != nil {
270277 sesh.Fatal(err)
271278 }
......@@ -275,6 +282,14 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
275282 }
276283 }
277284
285+ if cmd == "dns-check" {
286+ err = opts.dnsCheck(args[1])
287+ if err != nil {
288+ sesh.Fatal(err)
289+ }
290+ return nil
291+ }
292+
278293 if cmd == "not-found" {
279294 if len(args) < 3 {
280295 sesh.Fatal(fmt.Errorf("must provide host name and interval (`month` or `year`)"))
+149, -0
......@@ -0,0 +1,149 @@
1+package pico
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "strings"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/shared/router"
12+ "golang.org/x/crypto/ssh"
13+)
14+
15+// customDomainService is a service that serves a domain whose TXT record
16+// names it. check judges the records the way the service does and describes
17+// the result.
18+type customDomainService struct {
19+ space string
20+ check func(c *Cmd, records []string) string
21+ wildcard bool
22+}
23+
24+var customDomainServices = []customDomainService{
25+ {space: "pgs", check: siteCheck(resolvePgsSite)},
26+ {space: "prose", check: siteCheck(resolveProseSite)},
27+ // tuns accepts a match under either prefix; _sish is what sish uses.
28+ {space: "tuns", check: checkTunsRecords, wildcard: true},
29+ {space: "sish", check: checkTunsRecords, wildcard: true},
30+}
31+
32+// siteCheck checks a record whose value names a site. Like the services,
33+// it reads only the first record.
34+func siteCheck(resolve func(dbpool db.DB, value string) (*db.User, string, error)) func(c *Cmd, records []string) string {
35+ return func(c *Cmd, records []string) string {
36+ value := strings.TrimSpace(records[0])
37+ user, site, err := resolve(c.Dbpool, value)
38+ if err != nil {
39+ return fmt.Sprintf("%q is invalid: %v", value, err)
40+ }
41+ line := fmt.Sprintf("%q is valid, serving %s", value, site)
42+ if user.ID != c.User.ID {
43+ line += " (not your account)"
44+ }
45+ return line
46+ }
47+}
48+
49+func resolvePgsSite(dbpool db.DB, value string) (*db.User, string, error) {
50+ props, err := router.GetProjectFromSubdomain(value)
51+ if err != nil {
52+ return nil, "", err
53+ }
54+ user, err := dbpool.FindUserByName(props.Username)
55+ if err != nil {
56+ return nil, "", fmt.Errorf("user %q not found", props.Username)
57+ }
58+ if _, err := dbpool.FindProjectByName(user.ID, props.ProjectName); err != nil {
59+ return user, "", fmt.Errorf("user %q has no project %q", user.Name, props.ProjectName)
60+ }
61+ return user, fmt.Sprintf("pgs project %q of %s", props.ProjectName, user.Name), nil
62+}
63+
64+func resolveProseSite(dbpool db.DB, value string) (*db.User, string, error) {
65+ user, err := dbpool.FindUserByName(value)
66+ if err != nil {
67+ return nil, "", fmt.Errorf("user %q not found", value)
68+ }
69+ return user, fmt.Sprintf("prose blog of %s", user.Name), nil
70+}
71+
72+// checkTunsRecords looks for a record holding the user's name or the SHA256
73+// fingerprint of one of their keys. tuns compares the whole record, so it
74+// must match exactly.
75+func checkTunsRecords(c *Cmd, records []string) string {
76+ keys, err := c.Dbpool.FindKeysByUser(c.User)
77+ if err != nil {
78+ return fmt.Sprintf("could not load your keys: %v", err)
79+ }
80+ names := map[string]string{}
81+ for _, k := range keys {
82+ pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
83+ if err != nil {
84+ continue
85+ }
86+ names[ssh.FingerprintSHA256(pk)] = k.Name
87+ }
88+
89+ for _, r := range records {
90+ if r == c.User.Name {
91+ return fmt.Sprintf("%q is valid, tunnels from any of your keys can use this domain", r)
92+ }
93+ if name, ok := names[r]; ok {
94+ return fmt.Sprintf("%q is valid, tunnels from your key %q can use this domain", r, name)
95+ }
96+ }
97+ for _, r := range records {
98+ trimmed := strings.TrimSpace(r)
99+ if _, ok := names[trimmed]; ok || trimmed == c.User.Name {
100+ return fmt.Sprintf("%q is invalid: it has surrounding whitespace, and tuns needs an exact match", r)
101+ }
102+ }
103+ quoted := make([]string, len(records))
104+ for i, r := range records {
105+ quoted[i] = fmt.Sprintf("%q", r)
106+ }
107+ return fmt.Sprintf("%s is invalid: no record is your username or the SHA256 fingerprint of one of your keys", strings.Join(quoted, ", "))
108+}
109+
110+// dnsCheck reports, for each service, whether domain has a TXT record and
111+// whether the service would accept it.
112+func (c *Cmd) dnsCheck(domain string) error {
113+ domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
114+ base, wildcard := strings.CutPrefix(domain, "*.")
115+ if base == "" || strings.ContainsAny(base, "/:@ *") {
116+ return fmt.Errorf("expected a domain name like example.com or *.example.com")
117+ }
118+ lookup := c.lookupTXT
119+ if lookup == nil {
120+ lookup = router.LookupTXTRecords
121+ }
122+
123+ host := domain
124+ if wildcard {
125+ // tuns verifies a wildcard by looking up a random name under it.
126+ label := make([]byte, 6)
127+ _, _ = rand.Read(label)
128+ host = hex.EncodeToString(label) + "." + base
129+ }
130+
131+ for _, svc := range customDomainServices {
132+ name := router.TXTRecordName(host, svc.space)
133+ if wildcard && !svc.wildcard {
134+ c.output(fmt.Sprintf("%s: wildcard domains are not supported", router.TXTRecordName(domain, svc.space)))
135+ continue
136+ }
137+ records, err := lookup(host, svc.space)
138+ switch {
139+ case errors.Is(err, router.ErrNoTXTRecord):
140+ c.output(fmt.Sprintf("%s: no TXT record", name))
141+ case err != nil:
142+ c.output(fmt.Sprintf("%s: lookup failed: %v", name, err))
143+ default:
144+ c.output(fmt.Sprintf("%s: %s", name, svc.check(c, records)))
145+ }
146+ }
147+ 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.")
148+ return nil
149+}
+162, -0
......@@ -0,0 +1,162 @@
1+package pico
2+
3+import (
4+ "bytes"
5+ "crypto/ed25519"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "strings"
10+ "testing"
11+
12+ "github.com/picosh/pico/pkg/db"
13+ "github.com/picosh/pico/pkg/shared"
14+ "github.com/picosh/pico/pkg/shared/router"
15+ "golang.org/x/crypto/ssh"
16+)
17+
18+type fakeSession struct{ bytes.Buffer }
19+
20+func (s *fakeSession) Exit(int) error { return nil }
21+func (s *fakeSession) Close() error { return nil }
22+func (s *fakeSession) Stderr() io.ReadWriter { return &s.Buffer }
23+
24+type dnsCheckDB struct {
25+ db.DB
26+ users []*db.User
27+ projects map[string]bool
28+ keys []*db.PublicKey
29+}
30+
31+func (d *dnsCheckDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
32+ keys := []*db.PublicKey{}
33+ for _, k := range d.keys {
34+ if k.UserID == user.ID {
35+ keys = append(keys, k)
36+ }
37+ }
38+ return keys, nil
39+}
40+
41+func (d *dnsCheckDB) FindUserByName(name string) (*db.User, error) {
42+ for _, u := range d.users {
43+ if u.Name == name {
44+ return u, nil
45+ }
46+ }
47+ return nil, fmt.Errorf("user not found")
48+}
49+
50+func (d *dnsCheckDB) FindProjectByName(userID, name string) (*db.Project, error) {
51+ if d.projects[userID+"/"+name] {
52+ return &db.Project{UserID: userID, Name: name}, nil
53+ }
54+ return nil, fmt.Errorf("project not found")
55+}
56+
57+func TestDNSCheck(t *testing.T) {
58+ me := &db.User{ID: "1", Name: "antonio"}
59+ other := &db.User{ID: "2", Name: "erock"}
60+ edKey, _, err := ed25519.GenerateKey(nil)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ pk, err := ssh.NewPublicKey(edKey)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ fingerprint := ssh.FingerprintSHA256(pk)
69+ dbpool := &dnsCheckDB{
70+ users: []*db.User{me, other},
71+ projects: map[string]bool{"1/blog": true},
72+ keys: []*db.PublicKey{{UserID: me.ID, Name: "laptop", Key: shared.KeyForKeyText(pk)}},
73+ }
74+
75+ tests := []struct {
76+ name string
77+ domain string
78+ records map[string][]string
79+ want []string
80+ }{
81+ {"no records", "", map[string][]string{}, []string{
82+ "_pgs.example.com: no TXT record",
83+ "_prose.example.com: no TXT record",
84+ "_tuns.example.com: no TXT record",
85+ "_sish.example.com: no TXT record",
86+ }},
87+ {"valid", "", map[string][]string{
88+ "pgs": {"antonio-blog"},
89+ "prose": {"erock"},
90+ "sish": {"SHA256:someone-else", fingerprint},
91+ }, []string{
92+ `_pgs.example.com: "antonio-blog" is valid, serving pgs project "blog" of antonio`,
93+ `_prose.example.com: "erock" is valid, serving prose blog of erock (not your account)`,
94+ fmt.Sprintf(`_sish.example.com: %q is valid, tunnels from your key "laptop" can use this domain`, fingerprint),
95+ }},
96+ {"invalid", "", map[string][]string{
97+ "pgs": {"antonio-blgo"},
98+ "prose": {"nobody"},
99+ "sish": {"SHA256:someone-else"},
100+ }, []string{
101+ `_pgs.example.com: "antonio-blgo" is invalid: user "antonio" has no project "blgo"`,
102+ `_prose.example.com: "nobody" is invalid: user "nobody" not found`,
103+ `_sish.example.com: "SHA256:someone-else" is invalid: no record is your username or the SHA256 fingerprint of one of your keys`,
104+ }},
105+ {"tuns record with whitespace", "", map[string][]string{"sish": {fingerprint + " "}}, []string{
106+ fmt.Sprintf(`_sish.example.com: %q is invalid: it has surrounding whitespace, and tuns needs an exact match`, fingerprint+" "),
107+ }},
108+ {"tuns username", "", map[string][]string{"tuns": {"antonio"}}, []string{
109+ `_tuns.example.com: "antonio" is valid, tunnels from any of your keys can use this domain`,
110+ "_sish.example.com: no TXT record",
111+ }},
112+ {"wildcard", "*.example.com", map[string][]string{"pgs": {"antonio-blog"}, "sish": {"antonio"}}, []string{
113+ "_pgs.*.example.com: wildcard domains are not supported\r\n",
114+ "_prose.*.example.com: wildcard domains are not supported\r\n",
115+ `.example.com: "antonio" is valid, tunnels from any of your keys can use this domain`,
116+ "_tuns.",
117+ }},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ session := &fakeSession{}
122+ cmd := &Cmd{
123+ User: me,
124+ Session: session,
125+ Log: slog.New(slog.DiscardHandler),
126+ Dbpool: dbpool,
127+ lookupTXT: func(host, space string) ([]string, error) {
128+ if tt.domain == "" && host != "example.com" {
129+ t.Fatalf("looked up %q", host)
130+ }
131+ if tt.domain != "" && (!strings.HasSuffix(host, ".example.com") || strings.Contains(host, "*")) {
132+ t.Fatalf("looked up %q for %q", host, tt.domain)
133+ }
134+ if v, ok := tt.records[space]; ok {
135+ return v, nil
136+ }
137+ return nil, router.ErrNoTXTRecord
138+ },
139+ }
140+ domain := tt.domain
141+ if domain == "" {
142+ domain = " Example.COM. "
143+ }
144+ if err := cmd.dnsCheck(domain); err != nil {
145+ t.Fatal(err)
146+ }
147+ out := session.String()
148+ for _, line := range tt.want {
149+ if !strings.Contains(out, line) {
150+ t.Errorf("missing %q in output:\n%s", line, out)
151+ }
152+ }
153+ })
154+ }
155+
156+ cmd := &Cmd{User: me, Session: &fakeSession{}, Dbpool: dbpool}
157+ for _, bad := range []string{"https://example.com/", "*.", "a.*.example.com"} {
158+ if err := cmd.dnsCheck(bad); err == nil {
159+ t.Errorf("%q was accepted as a domain", bad)
160+ }
161+ }
162+}
+71, -9
......@@ -1,7 +1,6 @@
11 package httpcache
22
33 import (
4- "encoding/json"
54 "log/slog"
65 "net/http"
76 "net/http/httptest"
......@@ -241,7 +240,7 @@ func TestCacheVary(t *testing.T) {
241240 // VaryRequestHeaders snapshots the request header values that were present
242241 // when this entry was cached, keyed by the lowercase header name.
243242 cv.VaryRequestHeaders = map[string]string{"accept-encoding": "gzip"}
244- cacheValue, _ := json.Marshal(cv)
243+ cacheValue := cv.encode()
245244 handler.Cache.Add(cacheKey, cacheValue)
246245
247246 respMatch, _ := tc.DoWithHeaders(req, map[string][]string{
......@@ -385,7 +384,7 @@ func TestCacheValidation(t *testing.T) {
385384 cv := testCacheValue(250 * time.Second)
386385 cv.Header["ETag"] = []string{"ccc"}
387386 cv.Header["Last-Modified"] = []string{actualStr}
388- cacheValue, _ := json.Marshal(cv)
387+ cacheValue := cv.encode()
389388 handler.Cache.Add(cacheKey, cacheValue)
390389
391390 t.Run(tt.name, func(t *testing.T) {
......@@ -423,7 +422,7 @@ func TestCacheAge(t *testing.T) {
423422
424423 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
425424 cacheKey := handler.GetCacheKey(req)
426- cacheValue, _ := json.Marshal(testCacheValue(250 * time.Second))
425+ cacheValue := testCacheValue(250 * time.Second).encode()
427426 handler.Cache.Add(cacheKey, cacheValue)
428427
429428 resp, _ := tc.Do(req)
......@@ -509,7 +508,7 @@ func TestCacheRequestDirectives(t *testing.T) {
509508
510509 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
511510 cacheKey := handler.GetCacheKey(req)
512- cacheValue, _ := json.Marshal(testCacheValue(250 * time.Second))
511+ cacheValue := testCacheValue(250 * time.Second).encode()
513512 handler.Cache.Add(cacheKey, cacheValue)
514513
515514 t.Run(tt.name, func(t *testing.T) {
......@@ -762,7 +761,7 @@ func TestCache304NotModifiedMerge(t *testing.T) {
762761 staleCv.Header["ETag"] = []string{"\"abc\""}
763762 staleCv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
764763 staleCv.Body = []byte("original body")
765- cacheData, _ := json.Marshal(staleCv)
764+ cacheData := staleCv.encode()
766765 handler.Cache.Add(cacheKey, cacheData)
767766
768767 // First request with If-None-Match triggers validation; origin returns 304
......@@ -890,7 +889,7 @@ func TestCache304NoBody(t *testing.T) {
890889 cv.Header["ETag"] = []string{"\"abc\""}
891890 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
892891 cv.Body = []byte("original body")
893- cacheData, _ := json.Marshal(cv)
892+ cacheData := cv.encode()
894893 handler.Cache.Add(cacheKey, cacheData)
895894
896895 // Trigger revalidation — upstream returns 304 with a spurious body.
......@@ -944,7 +943,7 @@ func TestCache304IncludesCachedHeaders(t *testing.T) {
944943 cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
945944 cv.Header["Cache-Control"] = []string{"max-age=300"}
946945 cv.Body = []byte("<h1>hello</h1>")
947- cacheData, _ := json.Marshal(cv)
946+ cacheData := cv.encode()
948947 handler.Cache.Add(cacheKey, cacheData)
949948
950949 // Send conditional request that triggers a 304 from the cache layer
......@@ -1075,7 +1074,7 @@ func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
10751074 }
10761075 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
10771076 cv.Body = []byte("cached body")
1078- cacheData, _ := json.Marshal(cv)
1077+ cacheData := cv.encode()
10791078 handler.Cache.Add(cacheKey, cacheData)
10801079
10811080 resp, _ := tc.Do(req)
......@@ -1103,3 +1102,66 @@ func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
11031102 })
11041103 }
11051104 }
1105+
1106+// A range request that misses must not leave a partial response cached for
1107+// requests of the whole resource.
1108+func TestCacheRangeNotStored(t *testing.T) {
1109+ body := strings.Repeat("0123456789", 100)
1110+ upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1111+ http.ServeContent(w, r, "file.txt", time.Unix(1_700_000_000, 0), strings.NewReader(body))
1112+ })
1113+ handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1114+
1115+ req := httptest.NewRequest("GET", "http://example.com/file.txt", nil)
1116+ req.Header.Set("Range", "bytes=0-9")
1117+ rec := httptest.NewRecorder()
1118+ handler.ServeHTTP(rec, req)
1119+ if rec.Code != http.StatusPartialContent || rec.Body.String() != body[:10] {
1120+ t.Fatalf("range request got %d %q", rec.Code, rec.Body.String())
1121+ }
1122+
1123+ for _, want := range []string{"miss", "hit"} {
1124+ rec = httptest.NewRecorder()
1125+ handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/file.txt", nil))
1126+ if rec.Code != http.StatusOK || rec.Body.String() != body {
1127+ t.Fatalf("full request got %d with %d bytes", rec.Code, rec.Body.Len())
1128+ }
1129+ if status := rec.Header().Get("cache-status"); !strings.Contains(status, want) {
1130+ t.Fatalf("full request cache-status %q, want %s", status, want)
1131+ }
1132+ }
1133+}
1134+
1135+// Bodies over MaxBodySize stream through and are not stored, whether or not
1136+// upstream announces their length.
1137+func TestCacheMaxBodySize(t *testing.T) {
1138+ body := strings.Repeat("x", 1000)
1139+ for name, setLength := range map[string]bool{"content-length": true, "chunked": false} {
1140+ t.Run(name, func(t *testing.T) {
1141+ upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1142+ if setLength {
1143+ w.Header().Set("content-length", strconv.Itoa(len(body)))
1144+ }
1145+ for i := 0; i < len(body); i += 100 {
1146+ _, _ = w.Write([]byte(body[i : i+100]))
1147+ }
1148+ })
1149+ handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1150+ handler.MaxBodySize = 250
1151+
1152+ for range 2 {
1153+ rec := httptest.NewRecorder()
1154+ handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/big", nil))
1155+ if rec.Code != http.StatusOK || rec.Body.String() != body {
1156+ t.Fatalf("got %d with %d bytes", rec.Code, rec.Body.Len())
1157+ }
1158+ if status := rec.Header().Get("cache-status"); !strings.Contains(status, "miss") {
1159+ t.Fatalf("cache-status %q, want a miss", status)
1160+ }
1161+ }
1162+ if handler.Cache.Len() != 0 {
1163+ t.Fatalf("stored %d entries", handler.Cache.Len())
1164+ }
1165+ })
1166+ }
1167+}
+77, -1
......@@ -1,26 +1,77 @@
11 package httpcache
22
33 import (
4+ "encoding/binary"
5+ "encoding/json"
6+ "errors"
47 "net/http"
8+ "strconv"
59 "strings"
610 "time"
711 )
812
13+// responseWriter buffers an upstream response so it can be stored. A body
14+// larger than maxBody is not stored: what was buffered is sent and the rest
15+// streams straight through.
916 type responseWriter struct {
1017 http.ResponseWriter
1118 statusCode int
1219 body []byte
20+ maxBody int64
21+ streaming bool
22+ // streamStatus is the cache-status sent with a streamed response.
23+ streamStatus string
1324 }
1425
1526 func (rw *responseWriter) WriteHeader(code int) {
1627 rw.statusCode = code
28+ size, err := strconv.ParseInt(rw.Header().Get("content-length"), 10, 64)
29+ if err == nil && rw.tooLarge(size) {
30+ _ = rw.stream()
31+ }
1732 }
1833
1934 func (rw *responseWriter) Write(data []byte) (int, error) {
35+ if !rw.streaming && rw.tooLarge(int64(len(rw.body)+len(data))) {
36+ if err := rw.stream(); err != nil {
37+ return 0, err
38+ }
39+ }
40+ if rw.streaming {
41+ return rw.ResponseWriter.Write(data)
42+ }
2043 rw.body = append(rw.body, data...)
2144 return len(data), nil
2245 }
2346
47+// Flush passes through once the response is streaming. Flushing a buffered
48+// response would send its headers before it is known whether it is stored.
49+func (rw *responseWriter) Flush() {
50+ if rw.streaming {
51+ _ = http.NewResponseController(rw.ResponseWriter).Flush()
52+ }
53+}
54+
55+func (rw *responseWriter) tooLarge(size int64) bool {
56+ return rw.maxBody > 0 && size > rw.maxBody
57+}
58+
59+func (rw *responseWriter) stream() error {
60+ if rw.streaming {
61+ return nil
62+ }
63+ rw.streaming = true
64+ rw.Header().Set("cache-status", rw.streamStatus)
65+ rw.ResponseWriter.WriteHeader(rw.StatusCode())
66+ body := rw.body
67+ rw.body = nil
68+ if len(body) == 0 {
69+ return nil
70+ }
71+ _, err := rw.ResponseWriter.Write(body)
72+ return err
73+}
74+
2475 // Body returns the captured response body.
2576 func (rw *responseWriter) Body() []byte {
2677 return rw.body
......@@ -77,8 +128,33 @@ func (rw *responseWriter) ToCacheValue(r *http.Request) *CacheValue {
77128
78129 type CacheValue struct {
79130 Header map[string][]string `json:"headers"`
80- Body []byte `json:"body"`
131+ Body []byte `json:"-"`
81132 CreatedAt time.Time `json:"created_at"`
82133 StatusCode int `json:"status_code"`
83134 VaryRequestHeaders map[string]string `json:"vary_request_headers,omitempty"`
84135 }
136+
137+// encode stores the body after the JSON metadata so a cache hit decodes only
138+// the metadata.
139+func (cv *CacheValue) encode() []byte {
140+ meta, _ := json.Marshal(cv)
141+ out := make([]byte, 0, binary.MaxVarintLen64+len(meta)+len(cv.Body))
142+ out = binary.AppendUvarint(out, uint64(len(meta)))
143+ out = append(out, meta...)
144+ return append(out, cv.Body...)
145+}
146+
147+// decodeCacheValue reads an entry written by encode. The body shares memory
148+// with data, which the cache never modifies.
149+func decodeCacheValue(data []byte) (*CacheValue, error) {
150+ n, k := binary.Uvarint(data)
151+ if k <= 0 || n > uint64(len(data)-k) {
152+ return nil, errors.New("corrupt cache entry")
153+ }
154+ cv := &CacheValue{}
155+ if err := json.Unmarshal(data[k:k+int(n)], cv); err != nil {
156+ return nil, err
157+ }
158+ cv.Body = data[k+int(n):]
159+ return cv, nil
160+}
+41, -21
......@@ -1,7 +1,6 @@
11 package httpcache
22
33 import (
4- "encoding/json"
54 "errors"
65 "fmt"
76 "log/slog"
......@@ -52,6 +51,9 @@ type HttpCache struct {
5251 Upstream http.Handler
5352 Cache Cacher
5453 Logger *slog.Logger
54+ // MaxBodySize is the largest body that is stored. Larger responses
55+ // stream through uncached. Zero means no limit.
56+ MaxBodySize int64
5557 }
5658
5759 func NewHttpCache(log *slog.Logger, upstream http.Handler) *HttpCache {
......@@ -78,6 +80,10 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7880 cacheKey := c.GetCacheKey(r)
7981 log := c.Logger.With("cache_key", cacheKey)
8082
83+ // A partial response must not be stored under the key of the whole
84+ // resource, so a range request that misses goes straight upstream.
85+ hasRange := r.Header.Get("range") != ""
86+
8187 err := c.maybeUseCache(cacheKey, w, r)
8288 if err == nil {
8389 log.Info("cache hit")
......@@ -85,6 +91,15 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8591 return
8692 }
8793
94+ if hasRange {
95+ log.Info("cache miss for range request, passing upstream", "err", err)
96+ c.AddCacheMiss()
97+ w.Header().Set("cache-status", cacheStatusMiss(cacheKey, false))
98+ c.Upstream.ServeHTTP(w, r)
99+ c.AddUpstreamRequest()
100+ return
101+ }
102+
88103 // RFC 9111 5.2.1.7 only-if-cached - don't store new responses
89104 cacheContState := parseCacheControl(r.Header.Get("cache-control"))
90105 onlyIfCached := cacheContState.onlyIfCache
......@@ -105,8 +120,7 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
105120
106121 if errors.Is(err, ErrMustRevalidate) {
107122 if cachedData, exists := c.Cache.Get(cacheKey); exists {
108- var cachedValue CacheValue
109- if json.Unmarshal(cachedData, &cachedValue) == nil {
123+ if cachedValue, err := decodeCacheValue(cachedData); err == nil {
110124 if etag := getHeader(cachedValue.Header, "etag"); etag != "" {
111125 r.Header.Set("if-none-match", etag)
112126 }
......@@ -119,9 +133,17 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
119133
120134 log.Info("cache miss, requesting upstream", "err", err)
121135 c.AddCacheMiss()
122- wrapped := &responseWriter{ResponseWriter: w}
136+ wrapped := &responseWriter{
137+ ResponseWriter: w,
138+ maxBody: c.MaxBodySize,
139+ streamStatus: cacheStatusMiss(cacheKey, false),
140+ }
123141 c.Upstream.ServeHTTP(wrapped, r)
124142 c.AddUpstreamRequest()
143+ if wrapped.streaming {
144+ log.Info("not cachable", "err", "response body too large")
145+ return
146+ }
125147
126148 // RFC 9111 4.3.4 304 Not Modified
127149 // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
......@@ -135,10 +157,9 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
135157 return
136158 }
137159
138- var cacheValue CacheValue
139- err = json.Unmarshal(existingData, &cacheValue)
160+ cacheValue, err := decodeCacheValue(existingData)
140161 if err != nil {
141- log.Error("json unmarshal", "err", err)
162+ log.Error("decode cache entry", "err", err)
142163 wrapped.Send()
143164 return
144165 }
......@@ -161,7 +182,7 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
161182 }
162183 // Revalidation refreshes the entry -- reset CreatedAt so it's fresh again.
163184 cacheValue.CreatedAt = time.Now()
164- enc, _ := json.Marshal(cacheValue)
185+ enc := cacheValue.encode()
165186 log.Info("updating cached headers from 304 response")
166187 c.Cache.Remove(cacheKey)
167188 c.Cache.Add(cacheKey, enc)
......@@ -172,9 +193,9 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172193 // updated cached entry and return 304 if it still matches.
173194 r.Header.Set("if-none-match", clientIfNoneMatch)
174195 r.Header.Set("if-modified-since", clientIfModifiedSince)
175- valid := c.handleValidation(r, &cacheValue)
196+ valid := c.handleValidation(r, cacheValue)
176197 if valid {
177- hdr := stripForbiddenHeaders(w, &cacheValue)
198+ hdr := stripForbiddenHeaders(w, cacheValue)
178199 ageDur := calcAge(cacheValue.CreatedAt)
179200 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
180201 hdr.Set("cache-status", cacheStatusStale(cacheKey, wrapped.StatusCode()))
......@@ -187,15 +208,14 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
187208 // Client request was unconditional (or conditional but no longer matches)
188209 // serve the full cached response.
189210 log.Info("serving full cached response to client")
190- serveCache(w, c.Ttl, cacheKey, &cacheValue)
211+ serveCache(w, c.Ttl, cacheKey, cacheValue)
191212 return
192213 }
193214
194215 err = isResponseCachable(r, wrapped)
195216 if err == nil {
196217 log.Info("storing cache")
197- nextValue := wrapped.ToCacheValue(r)
198- enc, _ := json.Marshal(nextValue)
218+ enc := wrapped.ToCacheValue(r).encode()
199219 c.Cache.Remove(cacheKey)
200220 c.Cache.Add(cacheKey, enc)
201221 c.AddCacheItem(float64(len(enc)))
......@@ -409,10 +429,11 @@ func isResponseCachable(r *http.Request, resp *responseWriter) error {
409429 // 206, 300, 301,
410430 // 308, 404, 405,
411431 // 410, 414, 501.
432+// 206 is left out: the cache key does not include the range.
412433 func isCacheableStatusCode(code int) bool {
413434 switch code {
414435 case http.StatusOK, http.StatusNonAuthoritativeInfo, http.StatusNoContent,
415- http.StatusPartialContent, http.StatusMultipleChoices, http.StatusMovedPermanently,
436+ http.StatusMultipleChoices, http.StatusMovedPermanently,
416437 http.StatusPermanentRedirect, http.StatusNotFound, http.StatusMethodNotAllowed,
417438 http.StatusGone, http.StatusRequestURITooLong, http.StatusNotImplemented:
418439 return true
......@@ -559,14 +580,13 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
559580 return fmt.Errorf("no cache stored")
560581 }
561582
562- var cacheValue CacheValue
563- err := json.Unmarshal(data, &cacheValue)
583+ cacheValue, err := decodeCacheValue(data)
564584 if err != nil {
565- return fmt.Errorf("json unmarshal: %w", err)
585+ return fmt.Errorf("decode cache entry: %w", err)
566586 }
567587
568588 // RFC 9111 4.1 Vary - check if request matches cached Vary values
569- if !matchVary(r, &cacheValue) {
589+ if !matchVary(r, cacheValue) {
570590 return fmt.Errorf("vary mismatch")
571591 }
572592
......@@ -624,9 +644,9 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
624644 // RFC 9111 4.3 Validation - check validation headers first
625645 // RFC 9110 13 Conditional Requests
626646 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13
627- valid := c.handleValidation(r, &cacheValue)
647+ valid := c.handleValidation(r, cacheValue)
628648 if valid {
629- hdr := stripForbiddenHeaders(w, &cacheValue)
649+ hdr := stripForbiddenHeaders(w, cacheValue)
630650 ageDur := calcAge(cacheValue.CreatedAt)
631651 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
632652 hdr.Set("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
......@@ -658,7 +678,7 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
658678 return fmt.Errorf("response older than request max-age")
659679 }
660680
661- serveCache(w, freshness, cacheKey, &cacheValue)
681+ serveCache(w, freshness, cacheKey, cacheValue)
662682 return nil
663683 }
664684
+5, -3
......@@ -349,10 +349,12 @@ func (s *SSHServer) ListenAndServe() error {
349349 },
350350 }, []string{"command"})
351351
352- go func() {
353- mux := http.NewServeMux()
354- mux.Handle("/metrics", promhttp.Handler())
352+ // The handler reads the default registerer, so build it before the
353+ // listener is published rather than in the goroutine.
354+ mux := http.NewServeMux()
355+ mux.Handle("/metrics", promhttp.Handler())
355356
357+ go func() {
356358 srv := &http.Server{Addr: s.Config.PromListenAddr, Handler: mux}
357359
358360 go func() {
+55, -12
......@@ -2,6 +2,7 @@ package router
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "log/slog"
78 "net"
......@@ -9,6 +10,7 @@ import (
910 "net/http/pprof"
1011 "regexp"
1112 "strings"
13+ "time"
1214
1315 "github.com/hashicorp/golang-lru/v2/expirable"
1416 "github.com/picosh/pico/pkg/db"
......@@ -196,25 +198,66 @@ func GetSubdomain(r *http.Request) string {
196198
197199 var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout)
198200
199-func GetCustomDomain(host string, space string) string {
200- txt := fmt.Sprintf("_%s.%s", space, host)
201- record, found := txtCache.Get(txt)
202- if found {
203- return record
201+// txtMissCache remembers hosts without a TXT record for a short time, so
202+// unknown hosts don't cost a lookup per request and a record that was just
203+// added starts working quickly.
204+var txtMissCache = expirable.NewLRU[string, struct{}](2048, nil, 30*time.Second)
205+
206+var lookupTXT = net.LookupTXT
207+
208+// ErrNoTXTRecord is returned when a host has no TXT record for a service.
209+var ErrNoTXTRecord = errors.New("no TXT record")
210+
211+// TXTRecordName is the record that points host at a site on space.
212+func TXTRecordName(host, space string) string {
213+ return fmt.Sprintf("_%s.%s", space, host)
214+}
215+
216+// LookupTXTRecords asks DNS for host's records on space, bypassing the
217+// cache. No records returns ErrNoTXTRecord.
218+func LookupTXTRecords(host, space string) ([]string, error) {
219+ records, err := lookupTXT(TXTRecordName(host, space))
220+ var dnsErr *net.DNSError
221+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
222+ return nil, ErrNoTXTRecord
223+ }
224+ if err != nil {
225+ return nil, err
204226 }
227+ if len(records) == 0 {
228+ return nil, ErrNoTXTRecord
229+ }
230+ return records, nil
231+}
205232
206- records, err := net.LookupTXT(txt)
233+// LookupCustomDomain returns the site host's first record on space names,
234+// bypassing the cache.
235+func LookupCustomDomain(host, space string) (string, error) {
236+ records, err := LookupTXTRecords(host, space)
207237 if err != nil {
208- return ""
238+ return "", err
209239 }
240+ return strings.TrimSpace(records[0]), nil
241+}
210242
211- for _, v := range records {
212- rec := strings.TrimSpace(v)
213- txtCache.Add(txt, rec)
214- return rec
243+func GetCustomDomain(host string, space string) string {
244+ txt := TXTRecordName(host, space)
245+ if record, found := txtCache.Get(txt); found {
246+ return record
247+ }
248+ if _, missed := txtMissCache.Get(txt); missed {
249+ return ""
215250 }
216251
217- return ""
252+ record, err := LookupCustomDomain(host, space)
253+ if errors.Is(err, ErrNoTXTRecord) {
254+ txtMissCache.Add(txt, struct{}{})
255+ }
256+ if err != nil {
257+ return ""
258+ }
259+ txtCache.Add(txt, record)
260+ return record
218261 }
219262
220263 func GetApiToken(r *http.Request) string {
+52, -0
......@@ -0,0 +1,52 @@
1+package router
2+
3+import (
4+ "errors"
5+ "net"
6+ "testing"
7+)
8+
9+func TestGetCustomDomainCachesMisses(t *testing.T) {
10+ records := map[string][]string{}
11+ var lookups int
12+ var fail error
13+ lookupTXT = func(name string) ([]string, error) {
14+ lookups++
15+ if fail != nil {
16+ return nil, fail
17+ }
18+ if r, ok := records[name]; ok {
19+ return r, nil
20+ }
21+ return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
22+ }
23+ t.Cleanup(func() {
24+ lookupTXT = net.LookupTXT
25+ txtCache.Purge()
26+ txtMissCache.Purge()
27+ })
28+
29+ if got := GetCustomDomain("example.com", "pgs"); got != "" || lookups != 1 {
30+ t.Fatalf("got %q after %d lookups", got, lookups)
31+ }
32+ if got := GetCustomDomain("example.com", "pgs"); got != "" || lookups != 1 {
33+ t.Fatalf("missing record was looked up again: %q after %d lookups", got, lookups)
34+ }
35+
36+ // The uncached lookup sees a record as soon as it exists.
37+ records["_pgs.example.com"] = []string{" antonio-blog "}
38+ if got, err := LookupCustomDomain("example.com", "pgs"); got != "antonio-blog" || err != nil {
39+ t.Fatalf("LookupCustomDomain = %q, %v", got, err)
40+ }
41+
42+ // Failures other than a missing record are not cached.
43+ fail = errors.New("i/o timeout")
44+ if got := GetCustomDomain("other.com", "pgs"); got != "" {
45+ t.Fatalf("got %q", got)
46+ }
47+ fail = nil
48+ records["_pgs.other.com"] = []string{"antonio-other"}
49+ if got := GetCustomDomain("other.com", "pgs"); got != "antonio-other" {
50+ t.Fatalf("lookup after a transient failure got %q", got)
51+ }
52+}
+1, -1
......@@ -13,7 +13,7 @@ var menuChoices = []string{
1313 "invite",
1414 "tokens",
1515 "logs",
16- "access_logs",
16+ "access-logs",
1717 "analytics",
1818 "pages",
1919 "tuns",
+1, -1
......@@ -366,7 +366,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error {
366366 "analytics": NewAnalyticsPage(shrd),
367367 "chat": NewChatPage(shrd),
368368 "tuns": NewTunsPage(shrd),
369- "access_logs": NewAccessLogsPage(shrd),
369+ "access-logs": NewAccessLogsPage(shrd),
370370 "pages": NewPagesPage(shrd),
371371 }
372372 root := &App{