// Package net is the network domain. It owns every piece of relay protocol // state: connections, subscriptions, authentication and pending work. Reader // workers hand it parsed wire input; it answers with outbound frames, and it // is the only caller of the database, verifier and Blossom domains. package net import ( "crypto/rand" "encoding/hex" "errors" "fmt" "sort" "strconv" "strings" "time" "git.smesh.lol/torly/pkg/config" "git.smesh.lol/torly/pkg/logx" "git.smesh.lol/torly/pkg/nostr" tinyspawn "git.smesh.lol/torly/pkg/tinyspawn" "git.smesh.lol/torly/pkg/wire" ) const ( // The database and Blossom domains are single event loops. The network // domain must never block while handing them work: a blocking send can // deadlock when both domains fill their response buffers at once. maxDBOut = 8192 maxBlobOut = 1024 // A queued upload can be large. Cap the total bytes held in memory. maxBlobOutBytes = 256 << 20 ) const ( maxSubIDLen = 64 // pollInterval bounds message latency for worker-to-network traffic. pollInterval = 2 * time.Millisecond // pingInterval keeps idle WebSocket connections alive. Browsers do not // send their own pings, and the reader closes idle sockets after 150s. pingInterval = 50 * time.Second ) // WorkerLink is the pair of channels to one reader/writer worker pair. type WorkerLink struct { In tinyspawn.Duplex[wire.Inbound] Out tinyspawn.Duplex[wire.Outbound] } // Data is the domain's startup configuration. type Data struct { Cfg config.Config RelaySk [32]byte } // Chans are the channels bound at spawn. type Chans struct { Workers []WorkerLink DBReq tinyspawn.Duplex[wire.DBRequest] DBResp tinyspawn.Duplex[wire.DBResponse] BlobReq tinyspawn.Duplex[wire.BlobRequest] BlobResp tinyspawn.Duplex[wire.BlobResponse] Verifiers []tinyspawn.Duplex[wire.VerifyRequest] VerifyResp []tinyspawn.Duplex[wire.VerifyResponse] Ready chan<- wire.Ready Stop tinyspawn.Breaker } type connState struct { id wire.ConnID worker int ws bool authed bool pub [32]byte challenge string subs map[string][]nostr.Filter closed bool } const ( vkEvent uint8 = iota vkAuth ) type verifyCtx struct { conn *connState event *nostr.Event kind uint8 } const ( dbSave uint8 = iota dbQuery dbCount dbGraph ) type dbCtx struct { conn *connState sub string kind uint8 event *nostr.Event graph nostr.GraphQuery } type blobCtx struct { conn wire.ConnID worker int } type server struct { data Data ch Chans conns map[wire.ConnID]*connState pending map[wire.ReqID]*verifyCtx dbCtx map[wire.ReqID]*dbCtx blobCtx map[wire.ReqID]*blobCtx queue []*verifyCtx busy []bool nextReq wire.ReqID dbOut []wire.DBRequest blobOut []wire.BlobRequest blobOutBytes int } // Run is the domain entry point. func Run(data Data, ch Chans) { s := &server{ data: data, ch: ch, conns: make(map[wire.ConnID]*connState), pending: make(map[wire.ReqID]*verifyCtx), dbCtx: make(map[wire.ReqID]*dbCtx), blobCtx: make(map[wire.ReqID]*blobCtx), busy: make([]bool, len(ch.Verifiers)), } ch.Ready <- wire.Ready{OK: true} ticker := time.NewTicker(pollInterval) defer ticker.Stop() ping := time.NewTicker(pingInterval) defer ping.Stop() for { s.flush() worked := s.drain() s.dispatch() if worked { continue } select { case <-ticker.C: case <-ping.C: s.pingAll() case <-ch.Stop.Done(): return } } } func (s *server) drain() bool { worked := false for i := range s.ch.Workers { for j := 0; j < 32; j++ { select { case m := <-s.ch.Workers[i].In.Recv(): s.handleInbound(i, m) worked = true default: j = 32 } } } for { select { case r := <-s.ch.DBResp.Recv(): s.handleDB(r) worked = true default: goto verifyDrain } } verifyDrain: for i := range s.ch.VerifyResp { drained := false for !drained { select { case r := <-s.ch.VerifyResp[i].Recv(): s.busy[i] = false s.handleVerify(r) worked = true default: drained = true } } } for { select { case r := <-s.ch.BlobResp.Recv(): s.handleBlob(r) worked = true default: return worked } } } func (s *server) handleInbound(worker int, m wire.Inbound) { switch m.Kind { case wire.InOpened: if _, ok := s.conns[m.Conn]; ok { return } challenge := newChallenge() c := &connState{ id: m.Conn, worker: worker, ws: true, challenge: challenge, subs: make(map[string][]nostr.Filter), } s.conns[m.Conn] = c s.sendText(worker, m.Conn, authMsg(challenge)) case wire.InText: c := s.conns[m.Conn] if c == nil || c.closed { return } s.handleMessage(c, m.Text) case wire.InHTTP: s.handleHTTP(worker, m.Conn, m.HTTP) case wire.InClosed: if c := s.conns[m.Conn]; c != nil { c.closed = true delete(s.conns, m.Conn) } } } func (s *server) handleHTTP(worker int, id wire.ConnID, req wire.HTTPRequest) { if !s.data.Cfg.BlossomEnabled && req.Path != "/health" { body := []byte("not found\n") s.sendHTTP(worker, id, wire.HTTPResponse{ Status: 404, Header: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", strconv.Itoa(len(body))}}, Body: body, }) return } if req.Path == "/health" { body := fmt.Sprintf(`{"status":"ok","connections":%d}`, len(s.conns)) s.sendHTTP(worker, id, wire.HTTPResponse{ Status: 200, Header: [][2]string{{"Content-Type", "application/json"}, {"Content-Length", strconv.Itoa(len(body))}}, Body: []byte(body), }) return } s.nextReq++ rid := s.nextReq s.blobCtx[rid] = &blobCtx{conn: id, worker: worker} s.sendBlob(wire.BlobRequest{ ID: rid, Method: req.Method, Path: req.Path, Query: req.Query, Header: req.Header, Body: req.Body, }) } func (s *server) handleBlob(r wire.BlobResponse) { ctx := s.blobCtx[r.ID] if ctx == nil { return } delete(s.blobCtx, r.ID) if r.Handled { s.sendHTTP(ctx.worker, ctx.conn, wire.HTTPResponse{Status: r.Status, Header: r.Header, Body: r.Body}) return } body := []byte("not found\n") s.sendHTTP(ctx.worker, ctx.conn, wire.HTTPResponse{ Status: 404, Header: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", strconv.Itoa(len(body))}}, Body: body, }) } // pingAll sends a WebSocket ping to every open subscription connection. A // browser answers automatically, which resets the reader's idle deadline. func (s *server) pingAll() { for _, c := range s.conns { if c.closed || !c.ws { continue } s.send(c.worker, wire.Outbound{Conn: c.id, Kind: wire.OutPing}) } } func (s *server) handleMessage(c *connState, data []byte) { parts, err := nostr.SplitArray(data) if err != nil || len(parts) == 0 { s.notice(c, "invalid: message is not a JSON array") return } command, err := nostr.ParseJSONString(parts[0]) if err != nil { s.notice(c, "invalid: command is not a string") return } subID := func(raw []byte) (string, bool) { v, err := nostr.ParseJSONString(raw) if err != nil || len(v) == 0 || len(v) > maxSubIDLen { return "", false } return v, true } switch command { case "EVENT": if len(parts) != 2 { s.notice(c, "invalid: EVENT takes one event") return } s.handleEvent(c, parts[1]) case "REQ": if len(parts) < 3 { s.notice(c, "invalid: REQ needs a subscription id and at least one filter") return } sub, ok := subID(parts[1]) if !ok { s.notice(c, "invalid: bad subscription id") return } s.handleReq(c, sub, parts[2:]) case "CLOSE": if len(parts) != 2 { s.notice(c, "invalid: CLOSE takes a subscription id") return } v, err := nostr.ParseJSONString(parts[1]) if err != nil { s.notice(c, "invalid: bad subscription id") return } delete(c.subs, v) case "COUNT": if len(parts) < 3 { s.notice(c, "invalid: COUNT needs a subscription id and at least one filter") return } sub, ok := subID(parts[1]) if !ok { s.notice(c, "invalid: bad subscription id") return } s.handleCount(c, sub, parts[2:]) case "AUTH": if len(parts) != 2 { s.notice(c, "invalid: AUTH takes one event") return } s.handleAuth(c, parts[1]) default: s.notice(c, "invalid: unknown command "+command) } } func (s *server) handleEvent(c *connState, raw []byte) { if len(raw) > s.data.Cfg.MaxEventBytes { s.notice(c, "invalid: event is too large") return } ev, err := nostr.ParseEvent(raw) if err != nil { if id := nostr.ExtractID(raw); id != "" { s.ok(c, id, false, "invalid: "+err.Error()) } else { s.notice(c, "invalid: "+err.Error()) } return } idHex := nostr.Hex(ev.ID[:]) if err := s.validate(c, ev); err != nil { s.ok(c, idHex, false, err.Error()) return } if !ev.CheckID() { s.ok(c, idHex, false, "invalid: event id does not match contents") return } s.enqueueVerify(c, ev, vkEvent) } func (s *server) handleAuth(c *connState, raw []byte) { ev, err := nostr.ParseEvent(raw) if err != nil { s.notice(c, "invalid: "+err.Error()) return } idHex := nostr.Hex(ev.ID[:]) if err := s.validateAuth(c, ev); err != nil { s.ok(c, idHex, false, "auth-required: "+err.Error()) return } s.enqueueVerify(c, ev, vkAuth) } func (s *server) validate(c *connState, ev *nostr.Event) error { now := time.Now().Unix() if ev.CreatedAt > now+s.data.Cfg.MaxFutureSecs { return errors.New("invalid: created_at is too far in the future") } if ev.Expired(now) { return errors.New("invalid: event has expired") } if len(ev.Tags) > 2000 { return errors.New("invalid: too many tags") } if len(ev.Content) > s.data.Cfg.MaxEventBytes { return errors.New("invalid: content is too large") } if s.data.Cfg.AuthRequired && !c.authed { return errors.New("auth-required: authentication is required to publish") } if ev.IsProtected() && (!c.authed || c.pub != ev.PubKey) { return errors.New("auth-required: this protected event may only be published by its author") } return nil } func (s *server) validateAuth(c *connState, ev *nostr.Event) error { if ev.Kind != 22242 { return errors.New("wrong event kind") } now := time.Now().Unix() if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 { return errors.New("auth event timestamp is out of range") } challenge, ok := ev.Tags.First("challenge") if !ok || challenge != c.challenge { return errors.New("wrong challenge") } relayTag, ok := ev.Tags.First("relay") if !ok { return errors.New("missing relay tag") } if !relayURLMatches(relayTag, s.data.Cfg.RelayURL) { return errors.New("relay URL does not match") } if !ev.CheckID() { return errors.New("event id does not match contents") } return nil } func (s *server) enqueueVerify(c *connState, ev *nostr.Event, kind uint8) { ctx := &verifyCtx{conn: c, event: ev, kind: kind} s.queue = append(s.queue, ctx) } func (s *server) dispatch() { for len(s.queue) > 0 { slot := -1 for i := range s.busy { if !s.busy[i] { slot = i break } } if slot < 0 { return } ctx := s.queue[0] s.queue = s.queue[1:] s.nextReq++ rid := s.nextReq s.pending[rid] = ctx s.busy[slot] = true req := wire.VerifyRequest{ID: rid, Pub: ctx.event.PubKey, Msg: ctx.event.ID, Sig: ctx.event.Sig} select { case s.ch.Verifiers[slot].Tx <- req: case <-s.ch.Stop.Done(): s.busy[slot] = false return } } } func (s *server) handleVerify(r wire.VerifyResponse) { ctx := s.pending[r.ID] if ctx == nil { return } delete(s.pending, r.ID) if !r.OK { idHex := nostr.Hex(ctx.event.ID[:]) if ctx.kind == vkAuth { s.ok(ctx.conn, idHex, false, "auth-required: invalid signature") } else { s.ok(ctx.conn, idHex, false, "invalid: bad signature") } return } if ctx.kind == vkAuth { ctx.conn.authed = true ctx.conn.pub = ctx.event.PubKey s.ok(ctx.conn, nostr.Hex(ctx.event.ID[:]), true, "") return } s.accepted(ctx.conn, ctx.event) } func (s *server) accepted(c *connState, ev *nostr.Event) { idHex := nostr.Hex(ev.ID[:]) if ev.Kind == 22242 { s.ok(c, idHex, true, "") return } if ev.IsEphemeral() { s.ok(c, idHex, true, "") s.broadcast(ev) return } s.nextReq++ rid := s.nextReq s.dbCtx[rid] = &dbCtx{conn: c, kind: dbSave, event: ev} s.sendDB(wire.DBRequest{ID: rid, Kind: wire.DBSave, Event: ev.Clone()}) } func (s *server) handleReq(c *connState, sub string, rawFilters [][]byte) { filters, err := s.parseFilters(rawFilters) if err != nil { s.closed(c, sub, "invalid: "+err.Error()) return } if q := firstGraphQuery(filters); q != nil { s.handleGraphReq(c, sub, *q) return } if _, exists := c.subs[sub]; !exists && len(c.subs) >= s.data.Cfg.MaxSubsPerConn { s.closed(c, sub, "error: too many subscriptions") return } c.subs[sub] = filters s.nextReq++ rid := s.nextReq s.dbCtx[rid] = &dbCtx{conn: c, sub: sub, kind: dbQuery} s.sendDB(wire.DBRequest{ID: rid, Kind: wire.DBQuery, Filters: cloneFilters(filters), Limit: s.data.Cfg.QueryLimit}) } // firstGraphQuery returns the first _graph filter, matching orly: a graph // request is answered once and the remaining filters are ignored. func firstGraphQuery(filters []nostr.Filter) *nostr.GraphQuery { for i := range filters { if filters[i].Graph != nil { return filters[i].Graph } } return nil } func (s *server) handleGraphReq(c *connState, sub string, q nostr.GraphQuery) { // A REQ with the same subscription id replaces the old one. A graph query // is one-shot, so there is nothing to keep for live delivery. delete(c.subs, sub) if err := q.Validate(); err != nil { s.closed(c, sub, "invalid: "+err.Error()) return } s.nextReq++ rid := s.nextReq s.dbCtx[rid] = &dbCtx{conn: c, sub: sub, kind: dbGraph, graph: q} s.sendDB(wire.DBRequest{ID: rid, Kind: wire.DBGraph, Graph: q, Limit: s.data.Cfg.QueryLimit}) } func (s *server) handleCount(c *connState, sub string, rawFilters [][]byte) { filters, err := s.parseFilters(rawFilters) if err != nil { s.closed(c, sub, "invalid: "+err.Error()) return } if firstGraphQuery(filters) != nil { s.closed(c, sub, "error: _graph queries cannot be counted") return } s.nextReq++ rid := s.nextReq s.dbCtx[rid] = &dbCtx{conn: c, sub: sub, kind: dbCount} s.sendDB(wire.DBRequest{ID: rid, Kind: wire.DBCount, Filters: cloneFilters(filters)}) } // sendDB hands a request to the database domain without blocking the network // domain. If the request channel is full the request waits in dbOut and is // retried from Run. This keeps health and reads responsive while the database // is busy and prevents the two event loops from deadlocking on full channels. func (s *server) sendDB(req wire.DBRequest) { if s.ch.DBReq.TrySend(req) { return } if len(s.dbOut) >= maxDBOut { if ctx := s.dbCtx[req.ID]; ctx != nil { delete(s.dbCtx, req.ID) logx.Errorf("db queue full: dropping request id=%d kind=%d", req.ID, req.Kind) if ctx.conn != nil && !ctx.conn.closed { if ctx.kind == dbSave && ctx.event != nil { s.ok(ctx.conn, nostr.Hex(ctx.event.ID[:]), false, "error: relay busy") } else { s.closed(ctx.conn, ctx.sub, "error: relay busy") } } } return } s.dbOut = append(s.dbOut, req) } // sendBlob is the Blossom counterpart of sendDB. The byte cap bounds how much // queued upload data the network domain can retain. func (s *server) sendBlob(req wire.BlobRequest) { if s.ch.BlobReq.TrySend(req) { return } if len(s.blobOut) >= maxBlobOut || s.blobOutBytes+len(req.Body) > maxBlobOutBytes { ctx := s.blobCtx[req.ID] delete(s.blobCtx, req.ID) logx.Errorf("blob queue full: dropping request id=%d bytes=%d", req.ID, len(req.Body)) if ctx != nil { body := []byte("relay busy\n") s.sendHTTP(ctx.worker, ctx.conn, wire.HTTPResponse{ Status: 503, Header: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", strconv.Itoa(len(body))}}, Body: body, }) } return } s.blobOut = append(s.blobOut, req) s.blobOutBytes += len(req.Body) } // flush retries queued database and Blossom requests. It never blocks. func (s *server) flush() { for len(s.dbOut) > 0 { if !s.ch.DBReq.TrySend(s.dbOut[0]) { break } s.dbOut[0] = wire.DBRequest{} s.dbOut = s.dbOut[1:] } for len(s.blobOut) > 0 { if !s.ch.BlobReq.TrySend(s.blobOut[0]) { break } s.blobOutBytes -= len(s.blobOut[0].Body) s.blobOut[0] = wire.BlobRequest{} s.blobOut = s.blobOut[1:] } } func (s *server) handleDB(r wire.DBResponse) { ctx := s.dbCtx[r.ID] if ctx == nil { return } delete(s.dbCtx, r.ID) if ctx.conn == nil || ctx.conn.closed { return } switch ctx.kind { case dbSave: idHex := nostr.Hex(ctx.event.ID[:]) switch r.Code { case wire.ResDuplicate: s.ok(ctx.conn, idHex, true, "duplicate: already have this event") case wire.ResReplaced: s.ok(ctx.conn, idHex, false, "replaced: a newer event was already stored") case wire.ResDeleted: s.ok(ctx.conn, idHex, false, "deleted: this event was deleted") case wire.ResOK: s.ok(ctx.conn, idHex, true, "") s.broadcast(ctx.event) default: s.ok(ctx.conn, idHex, false, "error: could not store event") } case dbQuery: sortEvents(r.Events) for i := range r.Events { s.sendText(ctx.conn.worker, ctx.conn.id, eventMsg(ctx.sub, r.Events[i].AppendJSON(nil))) } s.sendText(ctx.conn.worker, ctx.conn.id, eoseMsg(ctx.sub)) case dbCount: s.sendText(ctx.conn.worker, ctx.conn.id, countMsg(ctx.sub, r.Count, r.Approximate)) case dbGraph: if r.Code == wire.ResError || r.Graph == nil { msg := "error: graph query failed" if r.Err != "" { msg = "error: " + r.Err } s.closed(ctx.conn, ctx.sub, msg) return } ev, err := s.graphEvent(ctx.graph, r.Graph) if err != nil { s.closed(ctx.conn, ctx.sub, "error: could not sign graph result") return } s.sendText(ctx.conn.worker, ctx.conn.id, eventMsg(ctx.sub, ev.AppendJSON(nil))) s.sendText(ctx.conn.worker, ctx.conn.id, eoseMsg(ctx.sub)) } } // graphEvent builds the relay-signed response event for a _graph query. func (s *server) graphEvent(q nostr.GraphQuery, res *nostr.GraphResult) (*nostr.Event, error) { ev := &nostr.Event{ Kind: nostr.KindGraphResult, CreatedAt: time.Now().Unix(), Tags: nostr.Tags{ {"edge", q.Edge}, {"direction", q.Direction}, {"seed", q.Seed}, {"depth", strconv.Itoa(q.Depth)}, {"elapsed", res.Elapsed}, }, Content: string(res.AppendContentJSON(nil)), } if err := ev.Sign(s.data.RelaySk); err != nil { return nil, err } return ev, nil } func (s *server) broadcast(ev *nostr.Event) { data := ev.AppendJSON(nil) for _, c := range s.conns { if c.closed { continue } for sub, filters := range c.subs { for i := range filters { if filterMatches(ev, &filters[i]) { s.sendText(c.worker, c.id, eventMsg(sub, data)) break } } } } } func (s *server) parseFilters(raw [][]byte) ([]nostr.Filter, error) { if len(raw) == 0 { return nil, errors.New("at least one filter is required") } if len(raw) > s.data.Cfg.MaxFilters { return nil, errors.New("too many filters") } filters := make([]nostr.Filter, 0, len(raw)) for _, r := range raw { f, err := nostr.ParseFilter(r) if err != nil { return nil, err } if f.Limit > s.data.Cfg.MaxLimit { f.Limit = s.data.Cfg.MaxLimit } filters = append(filters, *f) } return filters, nil } func (s *server) sendText(worker int, id wire.ConnID, data []byte) { s.send(worker, wire.Outbound{Conn: id, Kind: wire.OutText, Text: data}) } func (s *server) sendHTTP(worker int, id wire.ConnID, resp wire.HTTPResponse) { s.send(worker, wire.Outbound{Conn: id, Kind: wire.OutHTTP, HTTP: resp}) } func (s *server) send(worker int, out wire.Outbound) { if worker < 0 || worker >= len(s.ch.Workers) { return } select { case s.ch.Workers[worker].Out.Tx <- out: case <-s.ch.Stop.Done(): default: // A writer that cannot keep up loses the connection; its reader will // observe the close and report it back. } } func (s *server) ok(c *connState, id string, good bool, msg string) { s.sendText(c.worker, c.id, okMsg(id, good, msg)) } func (s *server) notice(c *connState, msg string) { s.sendText(c.worker, c.id, noticeMsg(msg)) } func (s *server) closed(c *connState, sub, msg string) { s.sendText(c.worker, c.id, closedMsg(sub, msg)) } func newChallenge() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { for i := range b { b[i] = byte(i * 7) } } return hex.EncodeToString(b) } func cloneFilters(in []nostr.Filter) []nostr.Filter { out := make([]nostr.Filter, len(in)) for i := range in { out[i] = in[i].Clone() } return out } func sortEvents(evs []nostr.Event) { sort.SliceStable(evs, func(i, j int) bool { if evs[i].CreatedAt != evs[j].CreatedAt { return evs[i].CreatedAt > evs[j].CreatedAt } return string(evs[i].ID[:]) < string(evs[j].ID[:]) }) } func relayURLMatches(a, b string) bool { norm := func(s string) string { s = strings.TrimSpace(s) for _, p := range []string{"wss://", "ws://", "https://", "http://"} { s = strings.TrimPrefix(s, p) } return strings.TrimSuffix(strings.ToLower(s), "/") } return a == b || norm(a) == norm(b) }