tinyrelayfile
tinyrelay/internal/sites/service.go/

internal/sites/service.go raw
1package sites
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "crypto/tls"
8 "database/sql"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net"
15 "net/http"
16 "net/url"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/FelineStateMachine/tinyrelay/internal/event"
22 "github.com/FelineStateMachine/tinyrelay/internal/policy"
23 "github.com/FelineStateMachine/tinyrelay/internal/storage"
24)
25
26// Blob is a fetched site file and its declared content metadata.
27type Blob struct {
28 Body io.ReadCloser
29 Type string
30 Size int64
31}
32
33// BlobGet opens a local blob by its SHA-256 hash. The caller closes Blob.Body.
34type BlobGet func(ctx context.Context, blobSHA string) (Blob, error)
35
36// BlobPut stores bytes under blobSHA with the supplied content type. The
37// callback consumes the reader before returning.
38type BlobPut func(ctx context.Context, blobSHA string, contentType string, body io.Reader) error
39
40// Fetch performs an outbound site file request. The caller closes the
41// returned response body.
42type Fetch func(ctx context.Context, request *http.Request) (*http.Response, error)
43
44// ResolveIP resolves a host before an outbound site request is allowed.
45type ResolveIP func(ctx context.Context, host string) ([]net.IP, error)
46
47// ResolveHost resolves a request host to a site label.
48type ResolveHost func(ctx context.Context, host string) (label string, err error)
49
50// ReadAccess authorizes a request for site content.
51type ReadAccess func(request *http.Request) bool
52
53// Config supplies storage, blob, network and request policy dependencies.
54type Config struct {
55 Store *storage.Store
56 GetBlob BlobGet
57 PutBlob BlobPut
58 Fetch Fetch
59 ResolveIP ResolveIP
60 MaxFetchBytes int64
61 Policy func() policy.Policy
62 ReadAccess ReadAccess
63 BaseDomain string
64 ResolveHost ResolveHost
65 RelayURL func(*http.Request) string
66}
67
68type Service struct{ config Config }
69
70// Mirror copies every missing manifest file from its advertised servers into
71// the local blob store. It is safe to retry after a partial failure.
72func (s *Service) Mirror(ctx context.Context, manifest event.Event) error {
73 if err := ValidateManifest(manifest); err != nil {
74 return err
75 }
76 if !s.config.Policy().Features.Sites.Mirror {
77 return nil
78 }
79 if s.config.PutBlob == nil {
80 return errors.New("site mirror requires a blob writer")
81 }
82 for _, mapping := range SitePaths(manifest) {
83 if s.config.GetBlob != nil {
84 if local, err := s.config.GetBlob(ctx, mapping[2]); err == nil {
85 if local.Body != nil {
86 _ = local.Body.Close()
87 }
88 continue
89 }
90 }
91 blob, err := s.blob(ctx, manifest, mapping)
92 if err != nil {
93 return fmt.Errorf("mirror %s: %w", mapping[1], err)
94 }
95 data, err := readVerified(blob.Body, mapping[2], s.config.MaxFetchBytes)
96 _ = blob.Body.Close()
97 if err != nil {
98 return fmt.Errorf("verify %s: %w", mapping[1], err)
99 }
100 if err := s.config.PutBlob(ctx, mapping[2], chooseType(blob.Type, mapping[1]), bytes.NewReader(data)); err != nil {
101 return fmt.Errorf("store %s: %w", mapping[1], err)
102 }
103 }
104 return nil
105}
106
107// RunMirrorIntent resolves the event captured by storage.AddIntents and runs
108// the idempotent mirror operation. Unknown intent kinds are rejected so a
109// worker cannot accidentally execute a different work queue entry here.
110func (s *Service) RunMirrorIntent(ctx context.Context, intent storage.Intent) error {
111 if intent.Kind != "site-mirror" {
112 return fmt.Errorf("unsupported site intent %q", intent.Kind)
113 }
114 result, err := s.config.Store.Query(ctx, event.Filter{IDs: []string{intent.EventID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
115 if err != nil {
116 return err
117 }
118 if len(result.Events) == 0 {
119 return nil
120 }
121 return s.Mirror(ctx, result.Events[0])
122}
123
124func New(config Config) (*Service, error) {
125 if config.Store == nil {
126 return nil, errors.New("sites store is required")
127 }
128 if config.Policy == nil {
129 config.Policy = func() policy.Policy { return policy.Defaults("") }
130 }
131 if config.BaseDomain == "" {
132 config.BaseDomain = "localhost"
133 }
134 if config.ResolveIP == nil {
135 config.ResolveIP = func(ctx context.Context, host string) ([]net.IP, error) {
136 return net.DefaultResolver.LookupIP(ctx, "ip", host)
137 }
138 }
139 if config.MaxFetchBytes < 0 {
140 return nil, errors.New("sites maximum fetch bytes cannot be negative")
141 }
142 if err := ensureManifestSchema(context.Background(), config.Store.DB()); err != nil {
143 return nil, fmt.Errorf("sites schema: %w", err)
144 }
145 return &Service{config: config}, nil
146}
147
148func (s *Service) SaveOptions(e event.Event, now int64) storage.SaveOptions {
149 if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot {
150 return storage.SaveOptions{Now: now}
151 }
152 if err := ValidateManifest(e); err != nil {
153 return storage.SaveOptions{Now: now}
154 }
155 opts := storage.SaveOptions{Now: now, BeforeCommit: func(ctx context.Context, tx *sql.Tx) error { return s.applyManifest(ctx, tx, e) }}
156 if s.config.Policy().Features.Sites.Mirror {
157 opts.Intents = []storage.Intent{{Kind: "site-mirror", EventID: e.ID, Payload: SiteLabel(e)}}
158 }
159 return opts
160}
161
162func (s *Service) ApplyTx(ctx context.Context, tx *sql.Tx, e event.Event) error {
163 return s.applyManifest(ctx, tx, e)
164}
165
166// DeleteTx removes a manifest index entry in the same transaction that
167// removes its event. Callers should invoke this for deletion and replacement
168// paths so routing never points at an event that is no longer visible.
169func (s *Service) DeleteTx(ctx context.Context, tx *sql.Tx, eventID string) error {
170 _, err := tx.ExecContext(ctx, "DELETE FROM site_manifests WHERE event_id=?", eventID)
171 return err
172}
173
174func (s *Service) applyManifest(ctx context.Context, tx *sql.Tx, e event.Event) error {
175 if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot {
176 return nil
177 }
178 label := SiteLabel(e)
179 if label == "" {
180 return errors.New("invalid: site label")
181 }
182 _, err := tx.ExecContext(ctx, `INSERT INTO site_manifests(label,event_id,kind,pubkey,d,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(label) DO UPDATE SET event_id=excluded.event_id,kind=excluded.kind,pubkey=excluded.pubkey,d=excluded.d,updated_at=excluded.updated_at`, label, e.ID, e.Kind, e.PubKey, event.Tag(e, "d"), time.Now().Unix())
183 return err
184}
185
186func (s *Service) Manifest(ctx context.Context, site Site) (event.Event, error) {
187 f := event.Filter{Kinds: []int{site.Kind}, Tags: map[string][]string{}}
188 if site.Kind == KindSiteSnapshot {
189 f.IDs = []string{site.ID}
190 } else {
191 f.Authors = []string{site.PubKey}
192 if site.Kind == KindNamedSite {
193 f.Tags["d"] = []string{site.D}
194 }
195 }
196 result, err := s.config.Store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
197 if err != nil {
198 return event.Event{}, err
199 }
200 if len(result.Events) == 0 {
201 return event.Event{}, sql.ErrNoRows
202 }
203 if err := ValidateManifest(result.Events[0]); err != nil {
204 return event.Event{}, err
205 }
206 return result.Events[0], nil
207}
208
209func (s *Service) Handler() http.Handler { return http.HandlerFunc(s.serve) }
210
211// MatchesHost reports whether the daemon should dispatch this Host header to
212// the site door before trying relay routes.
213func (s *Service) MatchesHost(host string) bool {
214 _, ok := s.labelForHost(context.Background(), host)
215 return ok
216}
217
218// HandlerForLabel returns a handler bound to a catalog-resolved site label.
219// It is useful when the daemon has already selected a tenant from its host
220// registry and must prevent a request from crossing into another tenant.
221func (s *Service) HandlerForLabel(label string) http.Handler {
222 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.serveLabel(w, r, label) })
223}
224
225func (s *Service) serve(w http.ResponseWriter, r *http.Request) {
226 label, ok := s.labelForHost(r.Context(), r.Host)
227 if !ok {
228 http.NotFound(w, r)
229 return
230 }
231 s.serveLabel(w, r, label)
232}
233
234func (s *Service) serveLabel(w http.ResponseWriter, r *http.Request, label string) {
235 if !s.config.Policy().Features.Sites.Enabled {
236 http.NotFound(w, r)
237 return
238 }
239 if s.config.ReadAccess != nil && !s.config.ReadAccess(r) {
240 http.Error(w, "authentication required", http.StatusUnauthorized)
241 return
242 }
243 site, ok := ParseSite(label)
244 if !ok {
245 http.NotFound(w, r)
246 return
247 }
248 manifest, err := s.Manifest(r.Context(), site)
249 if err != nil {
250 http.NotFound(w, r)
251 return
252 }
253 if r.URL.Path == "/.well-known/nostr.json" {
254 if _, present := r.URL.Query()["path"]; present {
255 s.discovery(w, r, manifest)
256 return
257 }
258 }
259 if r.Method != http.MethodGet && r.Method != http.MethodHead {
260 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
261 return
262 }
263 path, redirect, ok := sitePath(r.URL.EscapedPath(), SitePaths(manifest))
264 if !ok {
265 http.NotFound(w, r)
266 return
267 }
268 if redirect {
269 location := *r.URL
270 location.Path = path
271 location.RawPath = ""
272 location.Host = r.Host
273 location.Scheme = "http"
274 if r.TLS != nil {
275 location.Scheme = "https"
276 }
277 http.Redirect(w, r, location.String(), http.StatusPermanentRedirect)
278 return
279 }
280 if err := s.servePath(w, r, manifest, path); err != nil {
281 http.NotFound(w, r)
282 }
283}
284
285func (s *Service) discovery(w http.ResponseWriter, r *http.Request, manifest event.Event) {
286 w.Header().Set("Access-Control-Allow-Origin", "*")
287 w.Header().Set("Cache-Control", "no-store")
288 w.Header().Set("Access-Control-Allow-Headers", "authorization, content-type, accept")
289 w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
290 if r.Method == http.MethodOptions {
291 w.WriteHeader(http.StatusNoContent)
292 return
293 }
294 if r.Method != http.MethodGet && r.Method != http.MethodHead {
295 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
296 return
297 }
298 values, ok := r.URL.Query()["path"]
299 if !ok || len(values) != 1 {
300 writeDiscovery(w, http.StatusBadRequest, nil, r.Method == http.MethodHead)
301 return
302 }
303 path, ok := RequestedPath(values[0])
304 if !ok || !validDiscoveryPath(path) {
305 writeDiscovery(w, http.StatusBadRequest, nil, r.Method == http.MethodHead)
306 return
307 }
308 selected, redirect, valid := sitePath(path, SitePaths(manifest))
309 if redirect {
310 selected, _, valid = sitePath(path+"/", SitePaths(manifest))
311 }
312 if !valid || findPath(SitePaths(manifest), selected) == nil {
313 writeDiscovery(w, http.StatusOK, nil, r.Method == http.MethodHead)
314 return
315 }
316 filter := manifestFilter(manifest)
317 relay := ""
318 if s.config.RelayURL != nil {
319 relay = s.config.RelayURL(r)
320 }
321 if relay == "" {
322 relay = requestOrigin(r)
323 }
324 writeDiscovery(w, http.StatusOK, map[string]any{path: map[string]any{"filter": filter, "relays": []string{relay}}}, r.Method == http.MethodHead)
325}
326
327func manifestFilter(e event.Event) map[string]any {
328 if e.Kind == KindSiteSnapshot {
329 return map[string]any{"ids": []string{e.ID}, "limit": 1}
330 }
331 result := map[string]any{"authors": []string{e.PubKey}, "kinds": []int{e.Kind}, "limit": 1}
332 if e.Kind == KindNamedSite {
333 result["#d"] = []string{event.Tag(e, "d")}
334 }
335 return result
336}
337
338func validDiscoveryPath(path string) bool {
339 if path == "" || !strings.HasPrefix(path, "/") || strings.ContainsAny(path, "?#\\\x00\r\n") {
340 return false
341 }
342 for _, part := range strings.Split(path, "/") {
343 if part == ".." || part == "." {
344 return false
345 }
346 }
347 return true
348}
349
350func writeDiscovery(w http.ResponseWriter, status int, value any, head bool) {
351 w.Header().Set("Content-Type", "application/json")
352 w.WriteHeader(status)
353 if value != nil && !head {
354 raw, _ := json.Marshal(value)
355 _, _ = w.Write(raw)
356 }
357}
358
359func requestOrigin(r *http.Request) string {
360 scheme := "http"
361 if r.TLS != nil {
362 scheme = "https"
363 }
364 return scheme + "://" + r.Host
365}
366
367func (s *Service) labelForHost(ctx context.Context, host string) (string, bool) {
368 host = strings.ToLower(strings.Split(host, ":")[0])
369 if s.config.ResolveHost != nil {
370 label, err := s.config.ResolveHost(ctx, host)
371 if err == nil && label != "" {
372 if _, ok := ParseSite(label); ok {
373 return label, true
374 }
375 }
376 }
377 suffix := "." + strings.ToLower(strings.TrimSuffix(s.config.BaseDomain, "."))
378 if strings.HasSuffix(host, suffix) {
379 label := strings.TrimSuffix(host, suffix)
380 if _, ok := ParseSite(label); ok {
381 return label, true
382 }
383 }
384 for _, custom := range s.config.Policy().CustomHosts {
385 if strings.EqualFold(custom.Host, host) && custom.Site != "" {
386 return custom.Site, true
387 }
388 }
389 return "", false
390}
391
392func sitePath(raw string, paths [][]string) (string, bool, bool) {
393 decoded, err := url.PathUnescape(raw)
394 if err != nil {
395 return "", false, false
396 }
397 directory := !strings.Contains(pathBase(decoded), ".")
398 redirect := directory && !strings.HasSuffix(decoded, "/")
399 if redirect {
400 decoded += "/"
401 raw += "/"
402 }
403 lookup := decoded
404 if directory {
405 lookup += "index.html"
406 }
407 for _, tag := range paths {
408 if len(tag) == 3 && (tag[1] == raw || tag[1] == lookup) {
409 if redirect {
410 return decoded, true, true
411 }
412 return tag[1], redirect, true
413 }
414 }
415 return lookup, redirect, true
416}
417
418func pathBase(path string) string {
419 if i := strings.LastIndexByte(path, '/'); i >= 0 {
420 return path[i+1:]
421 }
422 return path
423}
424
425func (s *Service) servePath(w http.ResponseWriter, r *http.Request, manifest event.Event, path string) error {
426 mapping := findPath(SitePaths(manifest), path)
427 status := http.StatusOK
428 if mapping == nil {
429 mapping = findPath(SitePaths(manifest), "/404.html")
430 status = http.StatusNotFound
431 }
432 if mapping == nil {
433 return errors.New("site file not found")
434 }
435 blob, err := s.blob(r.Context(), manifest, mapping)
436 if err != nil {
437 return err
438 }
439 defer blob.Body.Close()
440 data, err := readVerified(blob.Body, mapping[2], s.config.MaxFetchBytes)
441 if err != nil {
442 return err
443 }
444 w.Header().Set("ETag", `"`+mapping[2]+`"`)
445 w.Header().Set("Content-Type", chooseType(blob.Type, path))
446 w.Header().Set("Content-Length", strconv.Itoa(len(data)))
447 cache := "public, no-cache, must-revalidate, no-transform"
448 if s.config.Policy().Reads != "open" {
449 cache = "private, no-store, no-transform"
450 }
451 w.Header().Set("Cache-Control", cache)
452 w.Header().Set("X-Content-Type-Options", "nosniff")
453 if r.Method == http.MethodHead {
454 w.WriteHeader(status)
455 return nil
456 }
457 if r.Header.Get("If-None-Match") == `"`+mapping[2]+`"` && status == http.StatusOK {
458 w.WriteHeader(http.StatusNotModified)
459 return nil
460 }
461 if status == http.StatusOK {
462 http.ServeContent(w, r, path, time.Time{}, bytes.NewReader(data))
463 return nil
464 }
465 w.WriteHeader(status)
466 _, err = w.Write(data)
467 return err
468}
469
470func findPath(paths [][]string, wanted string) []string {
471 for _, path := range paths {
472 if len(path) == 3 && path[1] == wanted {
473 return path
474 }
475 }
476 return nil
477}
478func chooseType(blobType, path string) string {
479 if blobType != "" {
480 return blobType
481 }
482 return SiteType(path)
483}
484func readVerified(body io.Reader, want string, maxBytes int64) ([]byte, error) {
485 limited := body
486 if maxBytes > 0 {
487 limited = io.LimitReader(body, maxBytes+1)
488 }
489 data, err := io.ReadAll(limited)
490 if err != nil {
491 return nil, err
492 }
493 if maxBytes > 0 && int64(len(data)) > maxBytes {
494 return nil, errors.New("site file exceeds fetch limit")
495 }
496 sum := sha256.Sum256(data)
497 if hex.EncodeToString(sum[:]) != want {
498 return nil, errors.New("site file hash mismatch")
499 }
500 return data, nil
501}
502
503func (s *Service) blob(ctx context.Context, manifest event.Event, mapping []string) (Blob, error) {
504 if s.config.GetBlob != nil {
505 if blob, err := s.config.GetBlob(ctx, mapping[2]); err == nil {
506 return blob, nil
507 }
508 }
509 if s.config.Fetch == nil {
510 return Blob{}, errors.New("site file is not local")
511 }
512 for _, raw := range s.servers(ctx, manifest) {
513 blob, err := s.fetchRemote(ctx, raw, mapping)
514 if err == nil {
515 return blob, nil
516 }
517 }
518 return Blob{}, errors.New("site file unavailable")
519}
520
521func (s *Service) fetchRemote(ctx context.Context, raw string, mapping []string) (Blob, error) {
522 u, err := url.Parse(raw)
523 if err != nil || !safeOrigin(u) || !s.publicOrigin(ctx, u) {
524 return Blob{}, errors.New("unsafe site origin")
525 }
526 u.Path = strings.TrimSuffix(u.Path, "/") + "/" + mapping[2]
527 u.RawQuery = ""
528 var response *http.Response
529 for redirects := 0; redirects <= 3; redirects++ {
530 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
531 if err != nil {
532 return Blob{}, err
533 }
534 response, err = s.fetch(ctx, req)
535 if err != nil {
536 return Blob{}, err
537 }
538 if response == nil || response.Body == nil {
539 return Blob{}, errors.New("empty origin response")
540 }
541 if response.StatusCode < 300 || response.StatusCode >= 400 {
542 break
543 }
544 location := response.Header.Get("location")
545 response.Body.Close()
546 if location == "" {
547 return Blob{}, errors.New("origin redirect has no location")
548 }
549 next, err := url.Parse(location)
550 if err != nil || !safeOrigin(next) || !s.publicOrigin(ctx, next) {
551 return Blob{}, errors.New("unsafe site redirect")
552 }
553 next.Path = strings.TrimSuffix(next.Path, "/") + "/" + mapping[2]
554 next.RawQuery = ""
555 u = next
556 if redirects == 3 {
557 return Blob{}, errors.New("too many origin redirects")
558 }
559 }
560 if response.StatusCode < 200 || response.StatusCode >= 300 {
561 response.Body.Close()
562 return Blob{}, errors.New("origin response was not successful")
563 }
564 if s.config.MaxFetchBytes > 0 && response.ContentLength > s.config.MaxFetchBytes {
565 response.Body.Close()
566 return Blob{}, errors.New("site file exceeds operator fetch limit")
567 }
568 return Blob{Body: response.Body, Type: response.Header.Get("content-type"), Size: response.ContentLength}, nil
569}
570
571func (s *Service) fetch(ctx context.Context, req *http.Request) (*http.Response, error) {
572 if s.config.Fetch != nil {
573 return s.config.Fetch(ctx, req)
574 }
575 return s.pinnedClient(ctx, req.URL.Hostname()).Do(req)
576}
577
578func (s *Service) pinnedClient(ctx context.Context, hostname string) *http.Client {
579 return &http.Client{Transport: &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{ServerName: hostname, MinVersion: tls.VersionTLS12}, DialContext: func(dialCtx context.Context, network, address string) (net.Conn, error) {
580 ips, err := s.config.ResolveIP(dialCtx, hostname)
581 if err != nil || len(ips) == 0 {
582 return nil, errors.New("origin DNS lookup failed")
583 }
584 for _, ip := range ips {
585 if !isPublicIP(ip) {
586 return nil, errors.New("origin resolved to a private address")
587 }
588 }
589 port := "443"
590 if _, p, err := net.SplitHostPort(address); err == nil {
591 port = p
592 }
593 return (&net.Dialer{}).DialContext(dialCtx, network, net.JoinHostPort(ips[0].String(), port))
594 }}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
595}
596
597func safeOrigin(u *url.URL) bool {
598 if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || net.ParseIP(u.Hostname()) != nil {
599 return false
600 }
601 return !strings.HasSuffix(u.Hostname(), ".local") && !strings.HasSuffix(u.Hostname(), ".internal")
602}
603
604func (s *Service) publicOrigin(ctx context.Context, u *url.URL) bool {
605 ips, err := s.config.ResolveIP(ctx, u.Hostname())
606 if err != nil || len(ips) == 0 {
607 return false
608 }
609 for _, ip := range ips {
610 if !isPublicIP(ip) {
611 return false
612 }
613 }
614 return true
615}
616
617func isPublicIP(ip net.IP) bool {
618 return !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
619}
620
621func (s *Service) servers(ctx context.Context, manifest event.Event) []string {
622 servers := append([]string(nil), event.TagValues(manifest, "server")...)
623 result, err := s.config.Store.Query(ctx, event.Filter{Authors: []string{manifest.PubKey}, Kinds: []int{10063}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
624 if err == nil && len(result.Events) > 0 {
625 servers = append(servers, event.TagValues(result.Events[0], "server")...)
626 }
627 seen := make(map[string]bool, len(servers))
628 out := make([]string, 0, len(servers))
629 for _, value := range servers {
630 if !seen[value] {
631 seen[value] = true
632 out = append(out, value)
633 }
634 }
635 return out
636}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs