tinyrelayfile
tinyrelay/internal/daemon/http.go/

internal/daemon/http.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "html/template"
8 "io"
9 "net"
10 "net/http"
11 "net/url"
12 "strings"
13 "time"
14
15 "github.com/FelineStateMachine/tinyrelay/internal/blob"
16 "github.com/FelineStateMachine/tinyrelay/internal/domains"
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/relay"
19 "github.com/FelineStateMachine/tinyrelay/internal/storage"
20)
21
22func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
23 w.Header().Set("Access-Control-Allow-Origin", "*")
24 w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-SHA-256, X-Content-Length, X-Content-Type, Upload-Type, Upload-Length, Upload-Offset")
25 w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")
26 w.Header().Set("Access-Control-Expose-Headers", "Allow, X-Reason")
27 w.Header().Set("X-Content-Type-Options", "nosniff")
28 if r.Method == http.MethodOptions {
29 if t.blobs != nil && t.Policy().Features.Files && blob.HandlesPath(r.URL.Path) {
30 t.blobs.Handler().ServeHTTP(w, r)
31 return
32 }
33 w.WriteHeader(http.StatusNoContent)
34 return
35 }
36 if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
37 t.healthHTTP(w, r)
38 return
39 }
40 if t.unclaimedSiteHost(r.Host) {
41 // A host under the site domain that no site claims must not fall
42 // through to the relay: signed requests would otherwise verify
43 // against a URL the relay does not own.
44 http.NotFound(w, r)
45 return
46 }
47 if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
48 t.router.HandleHTTP(w, r)
49 return
50 }
51 if isRoomStreamPath(r.URL.Path) {
52 t.roomStreamHTTP(w, r)
53 return
54 }
55 if r.URL.Path != "/backups/restore" && r.URL.Path != "/manage/jobs/status" {
56 ctx, done, err := t.beginOperation(r.Context())
57 if err != nil {
58 http.Error(w, err.Error(), http.StatusServiceUnavailable)
59 return
60 }
61 defer done()
62 r = r.WithContext(ctx)
63 }
64 if t.tryDataHTTP(w, r) {
65 return
66 }
67 if r.URL.Path == "/session" || r.URL.Path == "/session/logout" {
68 t.sessionHTTP(w, r)
69 return
70 }
71 if strings.HasPrefix(r.URL.Path, "/push/") || r.URL.Path == "/inbox/seen" {
72 t.pushHTTP(w, r)
73 return
74 }
75 if r.URL.Path == "/chat/preferences" {
76 t.chatPreferencesHTTP(w, r)
77 return
78 }
79 if r.URL.Path == "/mcp" {
80 t.mcpHTTP(w, r)
81 return
82 }
83 if r.URL.Path == "/llms.txt" {
84 t.llmsHTTP(w, r)
85 return
86 }
87 if t.tryBrowseHTTP(w, r) {
88 return
89 }
90 if t.blobs != nil && t.roomAttachmentHTTP(w, r) {
91 return
92 }
93 if isViewArtifactPath(r.URL.Path) {
94 t.customViews.viewArtifactHTTP(w, r)
95 return
96 }
97 if t.sites != nil && t.sites.MatchesHost(r.Host) {
98 t.sites.Handler().ServeHTTP(w, r)
99 return
100 }
101 if t.git != nil && (strings.HasPrefix(r.URL.Path, "/npub1") || strings.HasPrefix(r.URL.Path, "/npub/") || strings.HasPrefix(r.URL.Path, "/prs/")) {
102 p := t.Policy()
103 if !p.Features.Grasp || (strings.HasPrefix(r.URL.Path, "/prs/") && !p.Features.Grasp06) {
104 http.NotFound(w, r)
105 return
106 }
107 if p.Reads != "open" && !privatePolicy(p) {
108 http.Error(w, "restricted: public Git hosting requires open reads", http.StatusForbidden)
109 return
110 }
111 if privatePolicy(p) && strings.TrimSpace(r.Header.Get("Authorization")) == "" {
112 writeGRASP08Challenge(w)
113 return
114 }
115 if privatePolicy(p) {
116 proof, authErr := t.auth.VerifyGRASP08(r.Header.Get("Authorization"), t.requestURL(r))
117 if authErr != nil {
118 writeGRASP08Challenge(w)
119 return
120 }
121 if accessErr := t.requirePrivateAccess(r.Context(), proof.PubKey); accessErr != nil {
122 writeGRASP08Challenge(w)
123 return
124 }
125 r = privateGitRequest(r, proof)
126 }
127 t.git.ServeHTTP(w, r)
128 return
129 }
130 if t.blobs != nil && blob.HandlesPath(r.URL.Path) {
131 if !t.Policy().Features.Files {
132 http.NotFound(w, r)
133 return
134 }
135 t.blobs.Handler().ServeHTTP(w, r)
136 return
137 }
138 if r.Method == http.MethodPost && strings.Contains(r.Header.Get("Content-Type"), "application/nostr+json+rpc") {
139 t.manageHTTP(w, r)
140 return
141 }
142 if r.Method == http.MethodPost && (r.URL.Path == "/events" || r.URL.Path == "/query" || r.URL.Path == "/count") {
143 t.bridge(w, r)
144 return
145 }
146 if r.URL.Path == "/.well-known/nostr.json" {
147 if _, exists := r.URL.Query()["path"]; exists {
148 t.webAddress(w, r)
149 return
150 }
151 t.nip05(w, r)
152 return
153 }
154 if strings.Contains(r.Header.Get("Accept"), "application/nostr+json") {
155 t.information(w, r)
156 return
157 }
158 if t.ui != nil {
159 t.ui.ServeHTTP(w, r)
160 return
161 }
162 if r.URL.Path == "/" {
163 t.home(w, r)
164 return
165 }
166 http.NotFound(w, r)
167}
168
169func writeGRASP08Challenge(w http.ResponseWriter) {
170 w.Header().Set("WWW-Authenticate", `Nostr method="GET"`)
171 w.WriteHeader(http.StatusUnauthorized)
172}
173
174func (t *Tenant) webAddress(w http.ResponseWriter, r *http.Request) {
175 actor, err := t.resolveUIActor(r)
176 if err != nil {
177 http.Error(w, err.Error(), 401)
178 return
179 }
180 keys := []string{}
181 if actor != "" {
182 keys = append(keys, actor)
183 }
184 session := relay.Session{PubKeys: keys, RelayURL: t.RelayURL()}
185 domains.WebAddressHandler(domains.AddressConfig{
186 RelayURL: t.RelayURL(),
187 Authorized: func(req *http.Request) bool {
188 // resolveUIActor already verified and consumed the NIP-98 proof.
189 // Revalidating it here would reject every signed request as replay.
190 return req.Header.Get("Authorization") != "" && actor != ""
191 },
192 ReadAllowed: func(r *http.Request) bool {
193 _, err := t.gate.Read(r.Context(), []event.Filter{{}}, session)
194 return err == nil
195 },
196 Lookup: func(ctx context.Context, path string) (map[string]any, bool, error) {
197 filter, ok := AddressFilterForPath(path, t.Policy().Owner, t.records.PublicKey())
198 if !ok {
199 return nil, false, nil
200 }
201 raw, err := json.Marshal(filter)
202 if err != nil {
203 return nil, false, err
204 }
205 f, err := event.ParseFilter(raw)
206 if err != nil {
207 return nil, false, err
208 }
209 rows, err := t.Query(ctx, []event.Filter{f}, session)
210 return filter, len(rows) > 0, err
211 },
212 }).ServeHTTP(w, r)
213}
214
215func writeJSON(w http.ResponseWriter, status int, value any) {
216 raw, err := json.Marshal(value)
217 if err != nil {
218 http.Error(w, "could not encode response", 500)
219 return
220 }
221 w.Header().Set("Content-Type", "application/json; charset=utf-8")
222 w.WriteHeader(status)
223 if _, err := w.Write(raw); err != nil {
224 return
225 }
226}
227
228func (t *Tenant) requestBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
229 var reader io.Reader = r.Body
230 if t.app.cfg.MaxMessageBytes > 0 {
231 reader = http.MaxBytesReader(w, r.Body, t.app.cfg.MaxMessageBytes)
232 }
233 body, err := io.ReadAll(reader)
234 if err != nil {
235 return nil, err
236 }
237 return body, nil
238}
239
240func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) {
241 ip, _, err := net.SplitHostPort(r.RemoteAddr)
242 if err != nil {
243 ip = r.RemoteAddr
244 }
245 s := relay.Session{RelayURL: t.RelayURL(), RemoteIP: ip}
246 url := t.requestURL(r)
247 e, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), url, r.Method, string(body))
248 if err != nil {
249 return s, err
250 }
251 s.PubKeys = []string{e.PubKey}
252 return s, nil
253}
254
255// cookieReadSession lets a signed-in browser read through the query bridge
256// without a signature, so pages do not prompt the signer just to show data.
257// The Origin check keeps another site from reading with a visitor's cookie;
258// publishing still needs a signed event.
259func (t *Tenant) cookieReadSession(r *http.Request, s relay.Session) (relay.Session, error) {
260 base, err := url.Parse(t.requestURL(r))
261 if err != nil || r.Header.Get("Origin") != base.Scheme+"://"+base.Host {
262 return s, errors.New("auth-required: sign this request")
263 }
264 actor, err := t.cookieActor(r)
265 if err != nil {
266 return s, err
267 }
268 if actor == "" {
269 return s, errors.New("auth-required: sign this request")
270 }
271 s.PubKeys = []string{actor}
272 return s, nil
273}
274
275func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {
276 body, err := t.requestBody(w, r)
277 if err != nil {
278 writeJSON(w, 400, map[string]string{"error": err.Error()})
279 return
280 }
281 s, err := t.session(r, body)
282 if err != nil && r.URL.Path != "/events" && r.Header.Get("Authorization") == "" {
283 s, err = t.cookieReadSession(r, s)
284 }
285 if err != nil {
286 writeJSON(w, 401, map[string]string{"error": err.Error()})
287 return
288 }
289 if r.URL.Path == "/events" {
290 t.httpPublish(w, r, s, body)
291 return
292 }
293 var raw []json.RawMessage
294 if err := json.Unmarshal(body, &raw); err != nil || len(raw) == 0 {
295 writeJSON(w, 400, map[string]string{"error": "invalid: body must be a non-empty array of filters"})
296 return
297 }
298 filters := make([]event.Filter, len(raw))
299 for i, b := range raw {
300 f, err := event.ParseFilter(b)
301 if err != nil {
302 writeJSON(w, 400, map[string]string{"error": err.Error()})
303 return
304 }
305 filters[i] = f
306 }
307 var result any
308 if r.URL.Path == "/count" {
309 result, err = t.Count(r.Context(), filters, s)
310 } else {
311 result, err = t.Query(r.Context(), filters, s)
312 }
313 if err != nil {
314 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
315 return
316 }
317 writeJSON(w, 200, result)
318}
319
320func (t *Tenant) httpPublish(w http.ResponseWriter, r *http.Request, s relay.Session, body []byte) {
321 e, err := event.Parse(body)
322 if err != nil {
323 writeJSON(w, 400, map[string]string{"error": err.Error()})
324 return
325 }
326 message, err := t.router.Publish(r.Context(), e, s)
327 status := 200
328 if err != nil {
329 message = err.Error()
330 status = 400
331 }
332 writeJSON(w, status, map[string]any{"event_id": e.ID, "accepted": err == nil, "message": message})
333}
334
335func statusFor(err error) int {
336 if strings.HasPrefix(err.Error(), "auth-required:") {
337 return 401
338 }
339 if strings.HasPrefix(err.Error(), "restricted:") || strings.HasPrefix(err.Error(), "blocked:") {
340 return 403
341 }
342 return 400
343}
344
345func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {
346 p := t.Policy()
347 nips := []int{}
348 var agents *Capability
349 for _, capability := range t.Capabilities(nil) {
350 if capability.Status != "enabled" {
351 continue
352 }
353 if capability.ID == "agents" {
354 entry := capability
355 agents = &entry
356 }
357 if number, ok := capabilityNIPNumber(capability.ID); ok {
358 nips = append(nips, number)
359 }
360 }
361 name := p.Name
362 if name == "" {
363 name = t.meta.Name
364 }
365 limits := map[string]any{"max_subid_length": 64, "auth_required": p.Reads != "open", "payment_required": false, "restricted_writes": p.Writes != "open"}
366 if t.app.cfg.MaxMessageBytes > 0 {
367 limits["max_message_length"] = t.app.cfg.MaxMessageBytes
368 }
369 if p.MaxFuture > 0 {
370 limits["created_at_upper_limit"] = p.MaxFuture
371 }
372 if p.MinPow > 0 {
373 limits["min_pow_difficulty"] = p.MinPow
374 }
375 info := map[string]any{"name": name, "description": p.Description, "pubkey": p.Owner, "supported_nips": nips, "software": "https://github.com/FelineStateMachine/tinyrelay", "version": t.app.cfg.Version, "self_url": t.RelayURL(), "limitation": limits}
376 if privatePolicy(p) {
377 // GRASP-08 discovery is intentionally sparse. Clients need the service
378 // identity and protocol markers; tenant presentation metadata stays
379 // behind authenticated reads.
380 info = t.privateNIP11()
381 info["supported_nips"] = nips
382 info["software"] = "https://github.com/FelineStateMachine/tinyrelay"
383 info["version"] = t.app.cfg.Version
384 info["self_url"] = t.RelayURL()
385 info["limitation"] = limits
386 }
387 if t.records != nil {
388 info["self"] = t.records.PublicKey()
389 }
390 if p.Features.Grasp && t.git != nil {
391 info["supported_grasps"] = t.git.SupportedGRASPs()
392 }
393 if agents != nil {
394 info["agents"] = *agents
395 }
396 if p.Features.Sites.Enabled && !privatePolicy(p) {
397 base, _ := url.Parse(t.publicURL)
398 domain := t.siteDomain()
399 if base.Port() != "" {
400 domain += ":" + base.Port()
401 }
402 info["nsites"] = map[string]any{"host": t.siteDomain(), "kinds": []int{15128, 35128, 5128}, "root": base.Scheme + "://<npub>." + domain, "named": base.Scheme + "://<pubkeyB36><dTag>." + domain, "snapshot": base.Scheme + "://v<snapshotIdB36>." + domain}
403 }
404 for key, value := range map[string]string{"icon": p.Icon, "banner": p.Banner, "contact": p.Contact, "posting_policy": p.PostingPolicy, "privacy_policy": p.PrivacyPolicy} {
405 if privatePolicy(p) {
406 break
407 }
408 if value != "" {
409 info[key] = value
410 }
411 }
412 if p.JoinTerms != "" && !privatePolicy(p) {
413 info["terms_of_service"] = t.publicURL + "/terms"
414 }
415 if len(p.Tags) > 0 && !privatePolicy(p) {
416 info["tags"] = p.Tags
417 }
418 if len(p.LanguageTags) > 0 && !privatePolicy(p) {
419 info["language_tags"] = p.LanguageTags
420 }
421 if len(p.RelayCountries) > 0 && !privatePolicy(p) {
422 info["relay_countries"] = p.RelayCountries
423 }
424 raw, err := json.Marshal(info)
425 if err != nil {
426 http.Error(w, err.Error(), 500)
427 return
428 }
429 w.Header().Set("Content-Type", "application/nostr+json")
430 if _, err := w.Write(raw); err != nil {
431 return
432 }
433}
434
435func (t *Tenant) nip05(w http.ResponseWriter, r *http.Request) {
436 if !t.Policy().Features.Names {
437 http.NotFound(w, r)
438 return
439 }
440 if privatePolicy(t.Policy()) {
441 actor, err := t.resolveUIActor(r)
442 if err != nil || actor == "" {
443 http.Error(w, "auth-required: private service metadata requires NIP-98", http.StatusUnauthorized)
444 return
445 }
446 if err := t.requirePrivateAccess(r.Context(), actor); err != nil {
447 http.Error(w, err.Error(), http.StatusForbidden)
448 return
449 }
450 }
451 result, err := t.community.NIP05(r.Context(), r.URL.Query().Get("name"), t.RelayURL())
452 if err != nil {
453 writeJSON(w, 404, map[string]string{"error": "name not found"})
454 return
455 }
456 writeJSON(w, 200, result)
457}
458
459func (t *Tenant) home(w http.ResponseWriter, r *http.Request) {
460 tmpl, err := template.New("home").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>{{.Name}}</title><h1>{{.Name}}</h1><p>{{.Description}}</p><p>Relay: <code>{{.URL}}</code></p><p><a href="./people">People</a> | <a href="./manage">Manage relay</a></p></html>`)
461 if err != nil {
462 http.Error(w, "page unavailable", 500)
463 return
464 }
465 p := t.Policy()
466 name := p.Name
467 if name == "" {
468 name = t.meta.Name
469 }
470 w.Header().Set("Content-Type", "text/html; charset=utf-8")
471 if err := tmpl.Execute(w, map[string]string{"Name": name, "Description": p.Description, "URL": t.RelayURL()}); err != nil {
472 t.app.telemetry.Logger().Error("render page", "error", err)
473 }
474}
475
476func (t *Tenant) manageHTTP(w http.ResponseWriter, r *http.Request) {
477 body, err := t.requestBody(w, r)
478 if err != nil {
479 writeJSON(w, 400, map[string]string{"error": err.Error()})
480 return
481 }
482 var call struct {
483 Method string `json:"method"`
484 Params []json.RawMessage `json:"params"`
485 }
486 if err := json.Unmarshal(body, &call); err != nil {
487 writeJSON(w, 400, map[string]string{"error": "invalid: malformed management request"})
488 return
489 }
490 actor := ""
491 if call.Method != "supportedmethods" {
492 s, err := t.session(r, body)
493 if err != nil {
494 writeJSON(w, 401, map[string]string{"error": err.Error()})
495 return
496 }
497 actor = s.PubKeys[0]
498 }
499 result, err := t.Execute(r.Context(), actor, call.Method, call.Params)
500 if err != nil {
501 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
502 return
503 }
504 writeJSON(w, 200, map[string]any{"result": result})
505}
506
507// Execute dispatches a management method with positional JSON parameters.
508// actor is the authenticated public key, or empty for an anonymous caller.
509// The transport establishes actor identity; method handlers apply current
510// membership and resource permissions. Results retain each method's wire shape.
511func (t *Tenant) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
512 ctx, done, admissionErr := t.beginOperation(ctx)
513 if admissionErr != nil {
514 return nil, admissionErr
515 }
516 defer done()
517 if method == "supportedmethods" {
518 methods := append([]string{"supportedmethods", "getpolicy", "setpolicy", "stats", "listlisthistory", "restorelist"}, t.community.Methods()...)
519 methods = append(methods, "successionstatus", "setsuccession", "clearsuccession", "listpins", "pinevent", "unpinevent", "listviews", "notifytest", "notifystatus")
520 if t.config != nil {
521 methods = append(methods, "exportconfig", "importconfig", "planconfig", "applypreset", "listpresets")
522 }
523 if t.replication != nil {
524 methods = append(methods, t.replication.Methods()...)
525 }
526 methods = append(methods, ManagementMethods()...)
527 seen := make(map[string]bool)
528 result := []string{}
529 for _, method := range methods {
530 if !seen[method] {
531 seen[method] = true
532 result = append(result, method)
533 }
534 }
535 return result, nil
536 }
537 role, err := t.community.Role(ctx, actor)
538 if err != nil {
539 return nil, err
540 }
541 if result, handled, err := t.executeManagement(ctx, actor, method, params); handled {
542 return result, err
543 }
544 if containsString(t.community.Methods(), method) {
545 return t.executeCommunity(ctx, actor, method, params)
546 }
547 if method == "listlisthistory" {
548 return t.store.ListHistory(ctx, actor, time.Now().Unix())
549 }
550 if method == "restorelist" {
551 if len(params) == 0 {
552 return nil, errors.New("invalid: event id required")
553 }
554 var id string
555 if err := json.Unmarshal(params[0], &id); err != nil {
556 return nil, err
557 }
558 return t.store.RestoreHistory(ctx, storage.RestoreOptions{Owner: actor, EventID: id, Now: time.Now().Unix()})
559 }
560 if role != "owner" && role != "moderator" {
561 return nil, errors.New("restricted: owner or moderator required")
562 }
563 if t.records != nil && containsString([]string{"successionstatus", "setsuccession", "clearsuccession", "listpins", "pinevent", "unpinevent", "listviews", "notifytest", "notifystatus"}, method) {
564 return t.records.Execute(ctx, actor, method, params)
565 }
566 switch method {
567 case "getpolicy":
568 return t.Policy(), nil
569 case "setpolicy":
570 if role != "owner" {
571 return nil, errors.New("restricted: only the owner sets policy")
572 }
573 if len(params) == 0 {
574 return nil, errors.New("invalid: policy patch required")
575 }
576 var patch map[string]json.RawMessage
577 if err := json.Unmarshal(params[0], &patch); err != nil {
578 return nil, err
579 }
580 delete(patch, "owner")
581 return t.setPolicy(ctx, patch)
582 case "stats":
583 return t.store.Stats(ctx)
584 default:
585 if t.config != nil && containsString([]string{"exportconfig", "importconfig", "planconfig", "applypreset", "listpresets"}, method) {
586 if role != "owner" {
587 return nil, errors.New("restricted: only the owner manages configuration")
588 }
589 return t.config.Execute(ctx, method, params)
590 }
591 if t.replication != nil && containsString(t.replication.Methods(), method) {
592 if role != "owner" {
593 return nil, errors.New("restricted: only the owner manages replication")
594 }
595 return t.replication.ExecuteRaw(ctx, method, params)
596 }
597 return t.executeCommunity(ctx, actor, method, params)
598 }
599}
600
601func (t *Tenant) executeCommunity(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
602 if !communityACLMutation(method) {
603 return t.community.Execute(ctx, actor, method, params)
604 }
605 result, err := t.router.ApplyACLChange(ctx, func(changeCtx context.Context) (any, error) {
606 return t.community.Execute(changeCtx, actor, method, params)
607 }, "blocked: relay access policy changed; subscribe again")
608 return result, err
609}
610
611func communityACLMutation(method string) bool {
612 switch method {
613 case "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "approvejoin", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords":
614 return true
615 default:
616 return false
617 }
618}
619
620func containsString(values []string, wanted string) bool {
621 for _, value := range values {
622 if value == wanted {
623 return true
624 }
625 }
626 return false
627}
628
629// unclaimedSiteHost reports whether host is a subdomain of the site domain
630// that neither a hosted site nor a custom host claims.
631func (t *Tenant) unclaimedSiteHost(host string) bool {
632 host = strings.ToLower(strings.Split(host, ":")[0])
633 domain := strings.ToLower(t.siteDomain())
634 if domain == "" || host == domain || !strings.HasSuffix(host, "."+domain) {
635 return false
636 }
637 if t.sites != nil && t.sites.MatchesHost(host) {
638 return false
639 }
640 for _, custom := range t.Policy().CustomHosts {
641 if strings.EqualFold(custom.Host, host) {
642 return false
643 }
644 }
645 return true
646}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs