| 1 | package relay |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "net" |
| 11 | "net/http" |
| 12 | "net/url" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol" |
| 19 | "github.com/coder/websocket" |
| 20 | ) |
| 21 | |
| 22 | type Session struct { |
| 23 | // PubKeys contains public keys established by the connection's AUTH flow. |
| 24 | PubKeys []string |
| 25 | // RelayURL is the relay identity used when authenticating and publishing. |
| 26 | RelayURL string |
| 27 | // RemoteIP is the peer address observed by the HTTP server. |
| 28 | RemoteIP string |
| 29 | } |
| 30 | |
| 31 | // Backend publishes while the Router's write gate is held. Publish performs |
| 32 | // the backend's admission, persistence, duplicate handling or control action |
| 33 | // before returning. A nil error allows Router to fan out the event unless the |
| 34 | // returned reason identifies a duplicate. Persistence and delivery planning |
| 35 | // remain backend responsibilities for event kinds that require them. |
| 36 | type Backend interface { |
| 37 | Publish(ctx context.Context, event event.Event, session Session) (reason string, err error) |
| 38 | Query(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, error) |
| 39 | Count(ctx context.Context, filters []event.Filter, session Session) (any, error) |
| 40 | } |
| 41 | |
| 42 | // Reader-side privacy can be stricter than the query filter. It is checked for |
| 43 | // both historical and live delivery, including encrypted signer traffic. |
| 44 | type Reader interface { |
| 45 | CanRead(event event.Event, session Session) bool |
| 46 | } |
| 47 | |
| 48 | // FilterReader applies reader privacy with the subscription filter available |
| 49 | // to the policy decision. |
| 50 | type FilterReader interface { |
| 51 | CanReadFilter(event event.Event, session Session, filter *event.Filter) bool |
| 52 | } |
| 53 | |
| 54 | // QueryHints can return query results together with completion hints. Router |
| 55 | // places these values in the EOSE response; the relay backend uses values such |
| 56 | // as "finish", "more" and "auth". |
| 57 | type QueryHints interface { |
| 58 | QueryHints(ctx context.Context, filters []event.Filter, session Session) ([]event.Event, []string, error) |
| 59 | } |
| 60 | |
| 61 | // PolicyChanges can invalidate subscriptions in storage after an access |
| 62 | // policy revision. Router removes client state before invoking the callback. |
| 63 | type PolicyChanges interface { |
| 64 | CloseSubscriptions(session Session) |
| 65 | } |
| 66 | |
| 67 | // SyncBackend supplies the authorized event snapshot for a NIP-77 session. |
| 68 | // The Router holds its read gate while calling Sync, so a concurrent Publish |
| 69 | // cannot create an opening between snapshot selection and NEG-OPEN. |
| 70 | type SyncBackend interface { |
| 71 | Sync(ctx context.Context, filter event.Filter, session Session) ([]syncprotocol.Item, error) |
| 72 | } |
| 73 | |
| 74 | type Config struct { |
| 75 | // RelayURL is the default relay identity included in sessions. |
| 76 | RelayURL string |
| 77 | // RequestRelayURL optionally derives the relay identity from each request. |
| 78 | RequestRelayURL func(*http.Request) string |
| 79 | // OnAuthenticate checks the authenticated session candidate and may persist |
| 80 | // authentication bookkeeping. The Session argument is a value snapshot; |
| 81 | // Router records the newly authenticated public key after this callback. |
| 82 | OnAuthenticate func(context.Context, Session) error |
| 83 | // MaxMessageBytes limits websocket text messages. Zero leaves the limit |
| 84 | // unlimited; a negative value selects the 512 KiB default. |
| 85 | MaxMessageBytes int64 |
| 86 | // MaxPendingBytes bounds queued output per connection. |
| 87 | MaxPendingBytes int |
| 88 | // ReadLimit, WriteLimit and PingInterval control websocket liveness. |
| 89 | ReadLimit time.Duration |
| 90 | WriteLimit time.Duration |
| 91 | PingInterval time.Duration |
| 92 | // OriginPatterns is passed to the websocket origin check. |
| 93 | OriginPatterns []string |
| 94 | // OnConnection and OnSubscription receive +1 and -1 lifecycle deltas. |
| 95 | OnConnection func(int) |
| 96 | OnSubscription func(int) |
| 97 | } |
| 98 | |
| 99 | func (c Config) withDefaults() Config { |
| 100 | if c.MaxMessageBytes < 0 { |
| 101 | c.MaxMessageBytes = 512 << 10 |
| 102 | } |
| 103 | if c.MaxPendingBytes <= 0 { |
| 104 | c.MaxPendingBytes = 4 << 20 |
| 105 | } |
| 106 | if c.ReadLimit <= 0 { |
| 107 | c.ReadLimit = 60 * time.Second |
| 108 | } |
| 109 | if c.WriteLimit <= 0 { |
| 110 | c.WriteLimit = 10 * time.Second |
| 111 | } |
| 112 | if c.PingInterval <= 0 { |
| 113 | c.PingInterval = 30 * time.Second |
| 114 | } |
| 115 | return c |
| 116 | } |
| 117 | |
| 118 | type Router struct { |
| 119 | backend Backend |
| 120 | cfg Config |
| 121 | mu sync.RWMutex // publish is exclusive with REQ registration + query |
| 122 | clientsMu sync.Mutex |
| 123 | clients map[*client]struct{} |
| 124 | listenersMu sync.Mutex |
| 125 | listeners map[*listener]struct{} |
| 126 | closed chan struct{} |
| 127 | closeOnce sync.Once |
| 128 | clientsWG sync.WaitGroup |
| 129 | } |
| 130 | |
| 131 | type listener struct{ fn func(event.Event) } |
| 132 | |
| 133 | func New(backend Backend, cfg Config) *Router { |
| 134 | return &Router{backend: backend, cfg: cfg.withDefaults(), clients: make(map[*client]struct{}), listeners: make(map[*listener]struct{}), closed: make(chan struct{})} |
| 135 | } |
| 136 | |
| 137 | // Listen registers an in-process observer of live fan-out. It sees every |
| 138 | // event delivered to websocket subscribers, before the reader's own access |
| 139 | // check. The callback runs under the publish fence and must not block; the |
| 140 | // returned function removes it. |
| 141 | func (r *Router) Listen(fn func(event.Event)) func() { |
| 142 | l := &listener{fn: fn} |
| 143 | r.listenersMu.Lock() |
| 144 | r.listeners[l] = struct{}{} |
| 145 | r.listenersMu.Unlock() |
| 146 | return func() { |
| 147 | r.listenersMu.Lock() |
| 148 | delete(r.listeners, l) |
| 149 | r.listenersMu.Unlock() |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func (r *Router) HandleHTTP(w http.ResponseWriter, req *http.Request) { |
| 154 | if req.Method != http.MethodGet { |
| 155 | http.Error(w, "method not allowed" , http.StatusMethodNotAllowed) |
| 156 | return |
| 157 | } |
| 158 | conn, err := websocket.Accept(w, req, &websocket.AcceptOptions{OriginPatterns: r.cfg.OriginPatterns}) |
| 159 | if err != nil { |
| 160 | return |
| 161 | } |
| 162 | c := newClient(r, conn, req) |
| 163 | r.clientsMu.Lock() |
| 164 | select { |
| 165 | case <-r.closed: |
| 166 | r.clientsMu.Unlock() |
| 167 | _ = conn.Close(websocket.StatusGoingAway, "relay shutting down" ) |
| 168 | return |
| 169 | default: |
| 170 | } |
| 171 | r.clients[c] = struct{}{} |
| 172 | if r.cfg.OnConnection != nil { |
| 173 | r.cfg.OnConnection(1) |
| 174 | } |
| 175 | r.clientsWG.Add(1) |
| 176 | r.clientsMu.Unlock() |
| 177 | go c.run() |
| 178 | } |
| 179 | |
| 180 | // Publish is the shared ingress path for websocket EVENT and HTTP-generated |
| 181 | // events. It applies the same historical/live fence and fan-out rules. |
| 182 | func (r *Router) Publish(ctx context.Context, e event.Event, s Session) (string, error) { |
| 183 | r.mu.Lock() |
| 184 | defer r.mu.Unlock() |
| 185 | reason, err := r.backend.Publish(ctx, e, s) |
| 186 | if err == nil && !duplicateReason(reason) { |
| 187 | if accessEvent(e) { |
| 188 | r.closeSubscriptionsLocked("blocked: relay access policy changed; subscribe again" ) |
| 189 | } |
| 190 | r.fanout(e) |
| 191 | } |
| 192 | return reason, err |
| 193 | } |
| 194 | |
| 195 | func (r *Router) publishClient(c *client, e event.Event, s Session) (string, error) { |
| 196 | r.mu.Lock() |
| 197 | defer r.mu.Unlock() |
| 198 | reason, err := r.backend.Publish(c.ctx, e, s) |
| 199 | if err != nil { |
| 200 | return reason, err |
| 201 | } |
| 202 | // Queue the sender's OK before any live EVENT fanout on that socket. |
| 203 | c.replyOK(e.ID, true, reason) |
| 204 | if accessEvent(e) { |
| 205 | c.r.closeSubscriptionsLocked("blocked: relay access policy changed; subscribe again" ) |
| 206 | } |
| 207 | if reason == "" { |
| 208 | r.fanout(e) |
| 209 | } |
| 210 | return reason, nil |
| 211 | } |
| 212 | |
| 213 | // BroadcastGenerated delivers an event already durably accepted by an |
| 214 | // out-of-band producer. |
| 215 | func (r *Router) BroadcastGenerated(e event.Event) { |
| 216 | r.mu.Lock() |
| 217 | defer r.mu.Unlock() |
| 218 | r.fanout(e) |
| 219 | } |
| 220 | |
| 221 | // CommitGenerated runs an imported or server-generated event commit under the |
| 222 | // same historical/live fence as client EVENT publication. The callback must |
| 223 | // durably commit the event before returning; a returned error suppresses |
| 224 | // fanout. This prevents a REQ snapshot from racing an out-of-band broadcast. |
| 225 | func (r *Router) CommitGenerated(ctx context.Context, commit func(context.Context) (event.Event, error)) (event.Event, error) { |
| 226 | r.mu.Lock() |
| 227 | defer r.mu.Unlock() |
| 228 | e, err := commit(ctx) |
| 229 | if err == nil && e.ID != "" { |
| 230 | r.fanout(e) |
| 231 | } |
| 232 | return e, err |
| 233 | } |
| 234 | |
| 235 | func (r *Router) Close(ctx context.Context) error { |
| 236 | r.closeOnce.Do(func() { close(r.closed) }) |
| 237 | r.clientsMu.Lock() |
| 238 | cs := make([]*client, 0, len(r.clients)) |
| 239 | for c := range r.clients { |
| 240 | cs = append(cs, c) |
| 241 | } |
| 242 | r.clientsMu.Unlock() |
| 243 | for _, c := range cs { |
| 244 | c.close(websocket.StatusGoingAway, "relay shutting down" ) |
| 245 | } |
| 246 | select { |
| 247 | case <-ctx.Done(): |
| 248 | return ctx.Err() |
| 249 | case <-r.clientsDone(): |
| 250 | return nil |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // CloseSubscriptions invalidates all live subscriptions after a policy |
| 255 | // revision. Existing sockets remain usable for AUTH and a subsequent REQ. |
| 256 | func (r *Router) CloseSubscriptions(reason string) { |
| 257 | r.mu.Lock() |
| 258 | defer r.mu.Unlock() |
| 259 | r.closeSubscriptionsLocked(reason) |
| 260 | } |
| 261 | |
| 262 | // ApplyACLChange serializes an access-control mutation with REQ snapshots, |
| 263 | // NEG sessions, and live publication. A successful mutation invalidates both |
| 264 | // subscription families before another transport operation can begin. |
| 265 | func (r *Router) ApplyACLChange(ctx context.Context, change func(context.Context) (any, error), reason string) (any, error) { |
| 266 | r.mu.Lock() |
| 267 | defer r.mu.Unlock() |
| 268 | result, err := change(ctx) |
| 269 | if err == nil { |
| 270 | r.closeSubscriptionsLocked(reason) |
| 271 | } |
| 272 | return result, err |
| 273 | } |
| 274 | |
| 275 | func (r *Router) closeSubscriptionsLocked(reason string) { |
| 276 | if reason == "" { |
| 277 | reason = "blocked: relay policy changed" |
| 278 | } |
| 279 | r.clientsMu.Lock() |
| 280 | cs := make([]*client, 0, len(r.clients)) |
| 281 | for c := range r.clients { |
| 282 | cs = append(cs, c) |
| 283 | } |
| 284 | r.clientsMu.Unlock() |
| 285 | for _, c := range cs { |
| 286 | c.mu.Lock() |
| 287 | ids := make([]string, 0, len(c.subs)) |
| 288 | for id := range c.subs { |
| 289 | ids = append(ids, id) |
| 290 | delete(c.subs, id) |
| 291 | } |
| 292 | syncIDs := make([]string, 0, len(c.syncs)) |
| 293 | for id := range c.syncs { |
| 294 | syncIDs = append(syncIDs, id) |
| 295 | delete(c.syncs, id) |
| 296 | } |
| 297 | c.mu.Unlock() |
| 298 | for _, id := range ids { |
| 299 | if r.cfg.OnSubscription != nil { |
| 300 | r.cfg.OnSubscription(-1) |
| 301 | } |
| 302 | b, _ := json.Marshal([]any{"CLOSED" , id, reason}) |
| 303 | _ = c.enqueueBytes(b) |
| 304 | } |
| 305 | for _, id := range syncIDs { |
| 306 | b, _ := json.Marshal([]any{"NEG-CLOSE" , id}) |
| 307 | _ = c.enqueueBytes(b) |
| 308 | } |
| 309 | } |
| 310 | if p, ok := r.backend.(PolicyChanges); ok { |
| 311 | p.CloseSubscriptions(Session{RelayURL: r.cfg.RelayURL}) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | func (r *Router) clientsDone() <-chan struct{} { |
| 316 | done := make(chan struct{}) |
| 317 | go func() { r.clientsWG.Wait(); close(done) }() |
| 318 | return done |
| 319 | } |
| 320 | |
| 321 | type subscription struct{ filters []event.Filter } |
| 322 | type outbound struct { |
| 323 | data []byte |
| 324 | size int |
| 325 | } |
| 326 | |
| 327 | type client struct { |
| 328 | r *Router |
| 329 | conn *websocket.Conn |
| 330 | ctx context.Context |
| 331 | cancel context.CancelFunc |
| 332 | mu sync.Mutex |
| 333 | session Session |
| 334 | challenge string |
| 335 | subs map[string]subscription |
| 336 | syncs map[string]*syncprotocol.Session |
| 337 | queue []outbound |
| 338 | pending int |
| 339 | wake chan struct{} |
| 340 | done chan struct{} |
| 341 | once sync.Once |
| 342 | wg sync.WaitGroup |
| 343 | } |
| 344 | |
| 345 | func newClient(r *Router, conn *websocket.Conn, req *http.Request) *client { |
| 346 | ctx, cancel := context.WithCancel(context.Background()) |
| 347 | b := make([]byte, 16) |
| 348 | _, _ = rand.Read(b) |
| 349 | relayURL := r.cfg.RelayURL |
| 350 | if r.cfg.RequestRelayURL != nil { |
| 351 | if requested := strings.TrimSpace(r.cfg.RequestRelayURL(req)); requested != "" { |
| 352 | relayURL = requested |
| 353 | } |
| 354 | } |
| 355 | return &client{r: r, conn: conn, ctx: ctx, cancel: cancel, challenge: hex.EncodeToString(b), subs: make(map[string]subscription), syncs: make(map[string]*syncprotocol.Session), wake: make(chan struct{}, 1), done: make(chan struct{}), session: Session{RelayURL: relayURL, RemoteIP: remoteIP(req.RemoteAddr)}} |
| 356 | } |
| 357 | |
| 358 | func (c *client) run() { |
| 359 | defer c.r.clientsWG.Done() |
| 360 | c.wg.Add(1) |
| 361 | go c.writer() |
| 362 | _ = c.enqueue(fmt.Sprintf(`["AUTH",%q]` , c.challenge)) |
| 363 | c.readLoop() |
| 364 | c.close(websocket.StatusNormalClosure, "closed" ) |
| 365 | c.wg.Wait() |
| 366 | } |
| 367 | |
| 368 | func (c *client) readLoop() { |
| 369 | defer c.conn.CloseNow() |
| 370 | readLimit := c.r.cfg.MaxMessageBytes |
| 371 | if readLimit == 0 { |
| 372 | readLimit = -1 |
| 373 | } |
| 374 | c.conn.SetReadLimit(readLimit) |
| 375 | for { |
| 376 | // Read contexts must live for the connection. A per-message timeout |
| 377 | // measures time between application messages, so it closes a healthy |
| 378 | // idle session even when the websocket ping/pong control traffic is |
| 379 | // flowing. The writer owns protocol keepalive; cancellation closes a |
| 380 | // connection during shutdown. |
| 381 | typ, data, err := c.conn.Read(c.ctx) |
| 382 | if err != nil { |
| 383 | return |
| 384 | } |
| 385 | if typ != websocket.MessageText { |
| 386 | _ = c.enqueue(`["NOTICE","error: binary frames are not supported"]` ) |
| 387 | continue |
| 388 | } |
| 389 | if c.r.cfg.MaxMessageBytes > 0 && int64(len(data)) > c.r.cfg.MaxMessageBytes { |
| 390 | _ = c.enqueue(`["NOTICE","error: message too large"]` ) |
| 391 | continue |
| 392 | } |
| 393 | var raw []json.RawMessage |
| 394 | if err := json.Unmarshal(data, &raw); err != nil || len(raw) == 0 { |
| 395 | _ = c.enqueue(`["NOTICE","error: could not parse message"]` ) |
| 396 | continue |
| 397 | } |
| 398 | var typName string |
| 399 | if json.Unmarshal(raw[0], &typName) != nil { |
| 400 | _ = c.enqueue(`["NOTICE","error: message type must be a string"]` ) |
| 401 | continue |
| 402 | } |
| 403 | c.dispatch(typName, raw[1:]) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | func (c *client) dispatch(kind string, args []json.RawMessage) { |
| 408 | switch kind { |
| 409 | case "EVENT" : |
| 410 | c.handleEvent(args) |
| 411 | case "REQ" , "COUNT" : |
| 412 | c.handleRequest(kind, args) |
| 413 | case "CLOSE" : |
| 414 | c.handleClose(args) |
| 415 | case "AUTH" : |
| 416 | c.handleAuth(args) |
| 417 | case "NEG-OPEN" , "NEG-MSG" , "NEG-CLOSE" : |
| 418 | c.handleSync(kind, args) |
| 419 | default: |
| 420 | _ = c.enqueue(fmt.Sprintf(`["NOTICE","error: unknown message type %s"]` , kind)) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | func (c *client) handleSync(kind string, args []json.RawMessage) { |
| 425 | if len(args) < 1 { |
| 426 | _ = c.enqueue(fmt.Sprintf(`["NEG-ERR","",%q]` , "error: " +kind+" needs a subscription id" )) |
| 427 | return |
| 428 | } |
| 429 | var id string |
| 430 | if json.Unmarshal(args[0], &id) != nil || id == "" || len(id) > 64 { |
| 431 | _ = c.enqueue(`["NEG-ERR","","error: bad subscription id"]` ) |
| 432 | return |
| 433 | } |
| 434 | if kind == "NEG-CLOSE" { |
| 435 | c.mu.Lock() |
| 436 | delete(c.syncs, id) |
| 437 | c.mu.Unlock() |
| 438 | return |
| 439 | } |
| 440 | if kind == "NEG-OPEN" { |
| 441 | if len(args) < 3 { |
| 442 | c.syncError(id, "error: NEG-OPEN needs a filter and a message" ) |
| 443 | return |
| 444 | } |
| 445 | f, err := event.ParseFilter(args[1]) |
| 446 | if err != nil { |
| 447 | c.syncError(id, "invalid: bad filter: " +err.Error()) |
| 448 | return |
| 449 | } |
| 450 | var message string |
| 451 | if json.Unmarshal(args[2], &message) != nil { |
| 452 | c.syncError(id, "invalid: message must be hex" ) |
| 453 | return |
| 454 | } |
| 455 | if _, err := hex.DecodeString(message); err != nil { |
| 456 | c.syncError(id, "invalid: message must be hex" ) |
| 457 | return |
| 458 | } |
| 459 | b, ok := c.r.backend.(SyncBackend) |
| 460 | if !ok { |
| 461 | c.syncError(id, "unsupported: sync is switched off on this relay" ) |
| 462 | return |
| 463 | } |
| 464 | c.r.mu.RLock() |
| 465 | items, err := b.Sync(c.ctx, f, c.snapshot()) |
| 466 | if err == nil { |
| 467 | var sess *syncprotocol.Session |
| 468 | sess, err = syncprotocol.NewSession(items, false) |
| 469 | if err == nil { |
| 470 | var result syncprotocol.Result |
| 471 | result, err = sess.Reconcile(message) |
| 472 | if err == nil { |
| 473 | if result.Response == "" { |
| 474 | _ = c.enqueue(fmt.Sprintf(`["NEG-CLOSE",%q]` , id)) |
| 475 | } else { |
| 476 | c.mu.Lock() |
| 477 | c.syncs[id] = sess |
| 478 | c.mu.Unlock() |
| 479 | c.sendSyncMessage(id, result.Response) |
| 480 | } |
| 481 | } |
| 482 | } |
| 483 | } |
| 484 | c.r.mu.RUnlock() |
| 485 | if err != nil { |
| 486 | c.syncError(id, syncErrorReason(err)) |
| 487 | } |
| 488 | return |
| 489 | } |
| 490 | if len(args) < 2 { |
| 491 | c.syncError(id, "error: NEG-MSG needs a message" ) |
| 492 | return |
| 493 | } |
| 494 | var message string |
| 495 | if json.Unmarshal(args[1], &message) != nil { |
| 496 | c.syncError(id, "invalid: message must be hex" ) |
| 497 | return |
| 498 | } |
| 499 | if _, err := hex.DecodeString(message); err != nil { |
| 500 | c.syncError(id, "invalid: message must be hex" ) |
| 501 | return |
| 502 | } |
| 503 | c.mu.Lock() |
| 504 | sess := c.syncs[id] |
| 505 | c.mu.Unlock() |
| 506 | if sess == nil { |
| 507 | c.syncError(id, "closed: no such sync, send NEG-OPEN first" ) |
| 508 | return |
| 509 | } |
| 510 | result, err := sess.Reconcile(message) |
| 511 | if err != nil { |
| 512 | c.mu.Lock() |
| 513 | delete(c.syncs, id) |
| 514 | c.mu.Unlock() |
| 515 | c.syncError(id, "invalid: " +err.Error()) |
| 516 | return |
| 517 | } |
| 518 | if result.Response == "" { |
| 519 | c.mu.Lock() |
| 520 | delete(c.syncs, id) |
| 521 | c.mu.Unlock() |
| 522 | _ = c.enqueue(fmt.Sprintf(`["NEG-CLOSE",%q]` , id)) |
| 523 | } else { |
| 524 | c.sendSyncMessage(id, result.Response) |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | func (c *client) sendSyncMessage(id, message string) { |
| 529 | b, _ := json.Marshal([]any{"NEG-MSG" , id, message}) |
| 530 | _ = c.enqueueBytes(b) |
| 531 | } |
| 532 | |
| 533 | func (c *client) syncError(id, reason string) { |
| 534 | b, _ := json.Marshal([]any{"NEG-ERR" , id, reason}) |
| 535 | _ = c.enqueueBytes(b) |
| 536 | } |
| 537 | |
| 538 | func (c *client) handleEvent(args []json.RawMessage) { |
| 539 | if len(args) < 1 { |
| 540 | _ = c.enqueue(`["NOTICE","error: EVENT needs an event"]` ) |
| 541 | return |
| 542 | } |
| 543 | providedID := rawEventID(args[0]) |
| 544 | e, err := event.Parse(args[0]) |
| 545 | if err != nil { |
| 546 | c.replyOK(providedID, false, err.Error()) |
| 547 | return |
| 548 | } |
| 549 | if e.Kind == event.KIND_AUTH { |
| 550 | c.replyOK(e.ID, false, "blocked: AUTH events use AUTH" ) |
| 551 | return |
| 552 | } |
| 553 | reason, err := c.r.publishClient(c, e, c.snapshot()) |
| 554 | if err != nil { |
| 555 | c.replyOK(e.ID, false, err.Error()) |
| 556 | return |
| 557 | } |
| 558 | _ = reason |
| 559 | } |
| 560 | |
| 561 | func (c *client) handleRequest(kind string, args []json.RawMessage) { |
| 562 | if len(args) < 2 { |
| 563 | _ = c.enqueue(fmt.Sprintf(`["NOTICE",%q]` , "error: " +kind+" needs an id and at least one filter" )) |
| 564 | return |
| 565 | } |
| 566 | var id string |
| 567 | if json.Unmarshal(args[0], &id) != nil || id == "" || len(id) > 64 { |
| 568 | _ = c.enqueue(`["NOTICE","error: bad subscription id"]` ) |
| 569 | return |
| 570 | } |
| 571 | filters := make([]event.Filter, 0, len(args)-1) |
| 572 | for _, raw := range args[1:] { |
| 573 | f, err := event.ParseFilter(raw) |
| 574 | if err != nil { |
| 575 | c.closedSub(id, "invalid: bad filter: " +err.Error()) |
| 576 | return |
| 577 | } |
| 578 | filters = append(filters, f) |
| 579 | } |
| 580 | if kind == "COUNT" { |
| 581 | c.handleCount(id, filters) |
| 582 | return |
| 583 | } |
| 584 | c.r.mu.Lock() |
| 585 | c.mu.Lock() |
| 586 | _, replaced := c.subs[id] |
| 587 | c.subs[id] = subscription{filters: filters} |
| 588 | if !replaced && c.r.cfg.OnSubscription != nil { |
| 589 | c.r.cfg.OnSubscription(1) |
| 590 | } |
| 591 | c.mu.Unlock() |
| 592 | var hints []string |
| 593 | var events []event.Event |
| 594 | var err error |
| 595 | if hinted, ok := c.r.backend.(QueryHints); ok { |
| 596 | events, hints, err = hinted.QueryHints(c.ctx, filters, c.snapshot()) |
| 597 | } else { |
| 598 | events, err = c.r.backend.Query(c.ctx, filters, c.snapshot()) |
| 599 | } |
| 600 | if err == nil { |
| 601 | for _, e := range events { |
| 602 | matched := false |
| 603 | for _, f := range filters { |
| 604 | if event.Matches(f, e) && c.canRead(e, &f) { |
| 605 | matched = true |
| 606 | break |
| 607 | } |
| 608 | } |
| 609 | if matched { |
| 610 | if !c.sendEvent(id, e) { |
| 611 | err = errors.New("slow consumer" ) |
| 612 | break |
| 613 | } |
| 614 | } |
| 615 | } |
| 616 | } |
| 617 | if err == nil { |
| 618 | if len(hints) > 0 { |
| 619 | _ = c.enqueue(fmt.Sprintf(`["EOSE",%q,%s]` , id, mustJSON(hints))) |
| 620 | } else { |
| 621 | _ = c.enqueue(fmt.Sprintf(`["EOSE",%q]` , id)) |
| 622 | } |
| 623 | } |
| 624 | c.r.mu.Unlock() |
| 625 | if err != nil { |
| 626 | c.closedSub(id, queryErrorReason(err)) |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | func (c *client) handleCount(id string, filters []event.Filter) { |
| 631 | c.r.mu.RLock() |
| 632 | value, err := c.r.backend.Count(c.ctx, filters, c.snapshot()) |
| 633 | c.r.mu.RUnlock() |
| 634 | if err != nil { |
| 635 | c.closedSub(id, "error: count failed: " +err.Error()) |
| 636 | return |
| 637 | } |
| 638 | _ = c.enqueue(fmt.Sprintf(`["COUNT",%q,%s]` , id, mustJSON(value))) |
| 639 | } |
| 640 | |
| 641 | func (c *client) handleClose(args []json.RawMessage) { |
| 642 | if len(args) > 0 { |
| 643 | var id string |
| 644 | if json.Unmarshal(args[0], &id) == nil { |
| 645 | c.mu.Lock() |
| 646 | _, existed := c.subs[id] |
| 647 | delete(c.subs, id) |
| 648 | c.mu.Unlock() |
| 649 | if existed && c.r.cfg.OnSubscription != nil { |
| 650 | c.r.cfg.OnSubscription(-1) |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | func (c *client) handleAuth(args []json.RawMessage) { |
| 657 | if len(args) < 1 { |
| 658 | c.replyOK("" , false, "invalid: AUTH needs an event" ) |
| 659 | return |
| 660 | } |
| 661 | providedID := rawEventID(args[0]) |
| 662 | e, err := event.Parse(args[0]) |
| 663 | if err != nil { |
| 664 | c.replyOK(providedID, false, err.Error()) |
| 665 | return |
| 666 | } |
| 667 | if e.Kind != event.KIND_AUTH { |
| 668 | c.replyOK(e.ID, false, "invalid: auth event must be kind 22242" ) |
| 669 | return |
| 670 | } |
| 671 | if abs(time.Now().Unix()-e.CreatedAt) > 600 { |
| 672 | c.replyOK(e.ID, false, "invalid: auth event created_at is too far from the current time" ) |
| 673 | return |
| 674 | } |
| 675 | if event.Tag(e, "challenge" ) != c.challenge { |
| 676 | c.replyOK(e.ID, false, "invalid: auth challenge does not match" ) |
| 677 | return |
| 678 | } |
| 679 | if c.session.RelayURL != "" && normalizeAuthRelay(event.Tag(e, "relay" )) != normalizeAuthRelay(c.session.RelayURL) { |
| 680 | c.replyOK(e.ID, false, "invalid: auth relay tag does not name this relay" ) |
| 681 | return |
| 682 | } |
| 683 | candidate := c.snapshot() |
| 684 | seen := false |
| 685 | for _, key := range candidate.PubKeys { |
| 686 | if key == e.PubKey { |
| 687 | seen = true |
| 688 | break |
| 689 | } |
| 690 | } |
| 691 | if !seen { |
| 692 | candidate.PubKeys = append(candidate.PubKeys, e.PubKey) |
| 693 | } |
| 694 | if c.r.cfg.OnAuthenticate != nil { |
| 695 | if err := c.r.cfg.OnAuthenticate(c.ctx, candidate); err != nil { |
| 696 | c.replyOK(e.ID, false, "error: authentication bookkeeping failed: " +err.Error()) |
| 697 | return |
| 698 | } |
| 699 | } |
| 700 | if !seen { |
| 701 | c.mu.Lock() |
| 702 | c.session.PubKeys = append(c.session.PubKeys, e.PubKey) |
| 703 | c.mu.Unlock() |
| 704 | } |
| 705 | c.replyOK(e.ID, true, "" ) |
| 706 | } |
| 707 | |
| 708 | func (r *Router) fanout(e event.Event) { |
| 709 | r.clientsMu.Lock() |
| 710 | cs := make([]*client, 0, len(r.clients)) |
| 711 | for x := range r.clients { |
| 712 | cs = append(cs, x) |
| 713 | } |
| 714 | r.clientsMu.Unlock() |
| 715 | for _, x := range cs { |
| 716 | x.mu.Lock() |
| 717 | subs := make(map[string]subscription, len(x.subs)) |
| 718 | for id, s := range x.subs { |
| 719 | subs[id] = s |
| 720 | } |
| 721 | x.mu.Unlock() |
| 722 | for id, s := range subs { |
| 723 | for _, f := range s.filters { |
| 724 | if event.Matches(f, e) && x.canRead(e, &f) { |
| 725 | if !x.sendEvent(id, e) { |
| 726 | x.close(websocket.StatusPolicyViolation, "slow consumer" ) |
| 727 | } |
| 728 | break |
| 729 | } |
| 730 | } |
| 731 | } |
| 732 | } |
| 733 | r.listenersMu.Lock() |
| 734 | ls := make([]*listener, 0, len(r.listeners)) |
| 735 | for l := range r.listeners { |
| 736 | ls = append(ls, l) |
| 737 | } |
| 738 | r.listenersMu.Unlock() |
| 739 | for _, l := range ls { |
| 740 | l.fn(e) |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | func (c *client) canRead(e event.Event, f *event.Filter) bool { |
| 745 | if x, ok := c.r.backend.(FilterReader); ok { |
| 746 | return x.CanReadFilter(e, c.snapshot(), f) |
| 747 | } |
| 748 | if x, ok := c.r.backend.(Reader); ok { |
| 749 | return x.CanRead(e, c.snapshot()) |
| 750 | } |
| 751 | return true |
| 752 | } |
| 753 | func (c *client) snapshot() Session { |
| 754 | c.mu.Lock() |
| 755 | defer c.mu.Unlock() |
| 756 | s := c.session |
| 757 | s.PubKeys = append([]string(nil), s.PubKeys...) |
| 758 | return s |
| 759 | } |
| 760 | func (c *client) sendEvent(id string, e event.Event) bool { |
| 761 | b, _ := json.Marshal([]any{"EVENT" , id, e}) |
| 762 | return c.enqueueBytes(b) == nil |
| 763 | } |
| 764 | func (c *client) replyOK(id string, ok bool, reason string) { |
| 765 | b, _ := json.Marshal([]any{"OK" , id, ok, reason}) |
| 766 | _ = c.enqueueBytes(b) |
| 767 | } |
| 768 | func (c *client) closedSub(id, reason string) { |
| 769 | c.mu.Lock() |
| 770 | delete(c.subs, id) |
| 771 | c.mu.Unlock() |
| 772 | b, _ := json.Marshal([]any{"CLOSED" , id, reason}) |
| 773 | _ = c.enqueueBytes(b) |
| 774 | } |
| 775 | func (c *client) enqueue(s string) error { return c.enqueueBytes([]byte(s)) } |
| 776 | func (c *client) enqueueBytes(b []byte) error { |
| 777 | c.mu.Lock() |
| 778 | defer c.mu.Unlock() |
| 779 | if c.ctx.Err() != nil { |
| 780 | return c.ctx.Err() |
| 781 | } |
| 782 | if c.pending+len(b) > c.r.cfg.MaxPendingBytes { |
| 783 | return errors.New("slow consumer" ) |
| 784 | } |
| 785 | c.queue = append(c.queue, outbound{data: append([]byte(nil), b...), size: len(b)}) |
| 786 | c.pending += len(b) |
| 787 | select { |
| 788 | case c.wake <- struct{}{}: |
| 789 | default: |
| 790 | } |
| 791 | return nil |
| 792 | } |
| 793 | |
| 794 | func (c *client) writer() { |
| 795 | defer c.wg.Done() |
| 796 | ticker := time.NewTicker(c.r.cfg.PingInterval) |
| 797 | defer ticker.Stop() |
| 798 | for { |
| 799 | c.mu.Lock() |
| 800 | if len(c.queue) == 0 { |
| 801 | c.mu.Unlock() |
| 802 | select { |
| 803 | case <-c.wake: |
| 804 | case <-c.done: |
| 805 | return |
| 806 | case <-ticker.C: |
| 807 | ctx, cancel := context.WithTimeout(c.ctx, c.r.cfg.WriteLimit) |
| 808 | err := c.conn.Ping(ctx) |
| 809 | cancel() |
| 810 | if err != nil { |
| 811 | return |
| 812 | } |
| 813 | } |
| 814 | continue |
| 815 | } |
| 816 | item := c.queue[0] |
| 817 | c.queue = c.queue[1:] |
| 818 | c.pending -= item.size |
| 819 | c.mu.Unlock() |
| 820 | ctx, cancel := context.WithTimeout(c.ctx, c.r.cfg.WriteLimit) |
| 821 | err := c.conn.Write(ctx, websocket.MessageText, item.data) |
| 822 | cancel() |
| 823 | if err != nil { |
| 824 | return |
| 825 | } |
| 826 | } |
| 827 | } |
| 828 | func (c *client) close(code websocket.StatusCode, reason string) { |
| 829 | c.once.Do(func() { |
| 830 | close(c.done) |
| 831 | c.cancel() |
| 832 | _ = c.conn.Close(code, reason) |
| 833 | c.r.clientsMu.Lock() |
| 834 | delete(c.r.clients, c) |
| 835 | c.r.clientsMu.Unlock() |
| 836 | if c.r.cfg.OnConnection != nil { |
| 837 | c.r.cfg.OnConnection(-1) |
| 838 | } |
| 839 | c.mu.Lock() |
| 840 | subscriptions := len(c.subs) |
| 841 | c.subs = make(map[string]subscription) |
| 842 | c.mu.Unlock() |
| 843 | if c.r.cfg.OnSubscription != nil && subscriptions > 0 { |
| 844 | c.r.cfg.OnSubscription(-subscriptions) |
| 845 | } |
| 846 | }) |
| 847 | } |
| 848 | func mustJSON(v any) string { |
| 849 | b, _ := json.Marshal(v) |
| 850 | if len(b) == 0 { |
| 851 | return "null" |
| 852 | } |
| 853 | return string(b) |
| 854 | } |
| 855 | func abs(v int64) int64 { |
| 856 | if v < 0 { |
| 857 | return -v |
| 858 | } |
| 859 | return v |
| 860 | } |
| 861 | func normalizeRelay(v string) string { |
| 862 | u, err := url.Parse(strings.TrimSpace(v)) |
| 863 | if err != nil || u.Hostname() == "" { |
| 864 | return "" |
| 865 | } |
| 866 | return strings.ToLower(u.Hostname()) |
| 867 | } |
| 868 | |
| 869 | func normalizeAuthRelay(v string) string { |
| 870 | u, err := url.Parse(strings.TrimSpace(v)) |
| 871 | if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { |
| 872 | return "" |
| 873 | } |
| 874 | host := strings.ToLower(u.Hostname()) |
| 875 | if port := u.Port(); port != "" { |
| 876 | host += ":" + port |
| 877 | } |
| 878 | path := strings.TrimRight(u.EscapedPath(), "/" ) |
| 879 | if path == "" { |
| 880 | path = "/" |
| 881 | } |
| 882 | return strings.ToLower(u.Scheme) + "://" + host + path |
| 883 | } |
| 884 | |
| 885 | func remoteIP(address string) string { |
| 886 | if host, _, err := net.SplitHostPort(strings.TrimSpace(address)); err == nil { |
| 887 | return host |
| 888 | } |
| 889 | if ip := net.ParseIP(strings.TrimSpace(address)); ip != nil { |
| 890 | return ip.String() |
| 891 | } |
| 892 | return strings.TrimSpace(address) |
| 893 | } |
| 894 | |
| 895 | func rawEventID(raw json.RawMessage) string { |
| 896 | var probe struct { |
| 897 | ID string `json:"id"` |
| 898 | } |
| 899 | if json.Unmarshal(raw, &probe) != nil { |
| 900 | return "" |
| 901 | } |
| 902 | return probe.ID |
| 903 | } |
| 904 | |
| 905 | func queryErrorReason(err error) string { |
| 906 | if err == nil { |
| 907 | return "" |
| 908 | } |
| 909 | if knownProtocolReason(err.Error()) { |
| 910 | return err.Error() |
| 911 | } |
| 912 | return "error: query failed: " + err.Error() |
| 913 | } |
| 914 | |
| 915 | func syncErrorReason(err error) string { |
| 916 | if err == nil { |
| 917 | return "" |
| 918 | } |
| 919 | if knownProtocolReason(err.Error()) { |
| 920 | return err.Error() |
| 921 | } |
| 922 | return "error: sync failed: " + err.Error() |
| 923 | } |
| 924 | |
| 925 | func knownProtocolReason(reason string) bool { |
| 926 | for _, prefix := range []string{"auth-required:" , "restricted:" , "blocked:" , "invalid:" , "unsupported:" } { |
| 927 | if strings.HasPrefix(reason, prefix) { |
| 928 | return true |
| 929 | } |
| 930 | } |
| 931 | return false |
| 932 | } |
| 933 | |
| 934 | func accessEvent(e event.Event) bool { |
| 935 | if e.Kind == event.KIND_NIP43_JOIN && event.Tag(e, "claim" ) == "" { |
| 936 | // An access request waits for review and changes nobody's access. |
| 937 | return false |
| 938 | } |
| 939 | switch e.Kind { |
| 940 | case event.KIND_REPORT, event.KIND_VANISH, event.KIND_JOIN, event.KIND_LEAVE, |
| 941 | event.KIND_NIP43_JOIN, event.KIND_NIP43_LEAVE, event.KIND_PUT_USER, |
| 942 | event.KIND_REMOVE_USER, event.KIND_DELETE_EVENT, event.KIND_DELETE_GROUP: |
| 943 | return true |
| 944 | default: |
| 945 | return false |
| 946 | } |
| 947 | } |
| 948 | |
| 949 | func duplicateReason(reason string) bool { |
| 950 | return strings.HasPrefix(reason, "duplicate:" ) |
| 951 | } |