Compare commits

...

26 Commits

Author SHA1 Message Date
fiatjaf
5823515d27 streamlined connection closes on failure.
account for the fact that the time.Ticker channel is
not closed when the ticker is stopped.
2023-12-09 00:00:22 -03:00
fiatjaf
9273a4b809 use a special context for each REQ stored-events handler that can be canceled. 2023-12-08 23:48:30 -03:00
fiatjaf
ddfc9ab64a fun with connection contexts and context cancelations. 2023-12-08 22:51:00 -03:00
fiatjaf
375236cfe2 fix sign on error checking. 2023-12-06 21:32:48 -03:00
fiatjaf
35e801379a make NIP-42 actually work, with inferred ServiceURL if that's not manually set. 2023-12-06 15:03:53 -03:00
fiatjaf
22da06b629 new flow for auth based on "auth-required: " rejection messages. 2023-12-06 12:14:58 -03:00
fiatjaf
7bfde76ab1 example fix. 2023-12-06 12:14:27 -03:00
fiatjaf
ad92d0b051 return CLOSED if any of the filters get rejected. 2023-12-06 11:56:56 -03:00
fiatjaf
728417852e fix nip04 policy. 2023-11-29 12:30:18 -03:00
fiatjaf
3c1b062eb8 include original http.Request in WebSocket struct. 2023-11-29 12:26:04 -03:00
fiatjaf
84d01dc1d3 rename auth-related fields on WebSocket struct. 2023-11-29 12:23:21 -03:00
fiatjaf
888ac8c1c0 use updated released go-nostr. 2023-11-29 12:23:02 -03:00
fiatjaf
e1fd6aaa56 update examples plugins->policies 2023-11-29 12:22:37 -03:00
fiatjaf
386a89676a use go-nostr envelopes and support CLOSED when filters are rejected. 2023-11-28 22:43:06 -03:00
fiatjaf
90697ad3d3 OverwriteRelayInformation 2023-11-27 00:54:45 -03:00
fiatjaf
8c8a435a0b ensure supported_nips is always a list, even if empty. 2023-11-23 19:37:01 -03:00
fiatjaf
d608c67791 store websocket object under WS_KEY at the connection context. 2023-11-23 19:36:46 -03:00
fiatjaf
c0069f1e1b fix example in readme. 2023-11-23 19:36:20 -03:00
fiatjaf
7a221cf9f0 add missing return when checking id. 2023-11-22 17:30:34 -03:00
fiatjaf
194ec994d7 rename plugins to policies. 2023-11-22 17:11:05 -03:00
fiatjaf
d592bd95a9 AntiSyncBots policy. 2023-11-22 17:10:11 -03:00
fiatjaf
2edf754907 cors. 2023-11-20 09:07:52 -03:00
fiatjaf
18e4904a00 check id before signature and do not allow invalid ids. 2023-11-19 16:40:29 -03:00
fiatjaf
591b49fe73 do not log on normal websocket close. 2023-11-19 08:30:06 -03:00
fiatjaf
5db3b5fb8b use binary search in RestrictToSpecifiedKinds() 2023-11-18 23:23:01 -03:00
fiatjaf
dcdf86c4e4 allow filtering by tag on PreventTooManyIndexableTags 2023-11-18 12:55:05 -03:00
15 changed files with 257 additions and 194 deletions

View File

@@ -34,7 +34,7 @@ func main() {
relay.Info.Name = "my relay" relay.Info.Name = "my relay"
relay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" relay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
relay.Info.Description = "this is my custom relay" relay.Info.Description = "this is my custom relay"
relay.Info.IconURL = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images" relay.Info.Icon = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images"
// you must bring your own storage scheme -- if you want to have any // you must bring your own storage scheme -- if you want to have any
store := make(map[string]*nostr.Event, 120) store := make(map[string]*nostr.Event, 120)

View File

@@ -8,7 +8,7 @@ import (
"github.com/fiatjaf/eventstore/lmdb" "github.com/fiatjaf/eventstore/lmdb"
"github.com/fiatjaf/khatru" "github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/plugins" "github.com/fiatjaf/khatru/policies"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
) )
@@ -26,8 +26,8 @@ func main() {
relay.CountEvents = append(relay.CountEvents, db.CountEvents) relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent) relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.RejectEvent = append(relay.RejectEvent, plugins.PreventTooManyIndexableTags(10)) relay.RejectEvent = append(relay.RejectEvent, policies.PreventTooManyIndexableTags(10, nil, nil))
relay.RejectFilter = append(relay.RejectFilter, plugins.NoComplexFilters) relay.RejectFilter = append(relay.RejectFilter, policies.NoComplexFilters)
relay.OnEventSaved = append(relay.OnEventSaved, func(ctx context.Context, event *nostr.Event) { relay.OnEventSaved = append(relay.OnEventSaved, func(ctx context.Context, event *nostr.Event) {
}) })

View File

@@ -60,15 +60,20 @@ func main() {
return false, "" // anyone else can return false, "" // anyone else can
}, },
) )
relay.OnConnect = append(relay.OnConnect,
func(ctx context.Context) { // you can request auth by rejecting an event or a request with the prefix "auth-required: "
// request NIP-42 AUTH from everybody relay.RejectFilter = append(relay.RejectFilter,
relay.RequestAuth(ctx) func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
log.Printf("request from %s\n", pubkey)
return false, ""
}
return true, "auth-required: only authenticated users can read from this relay"
}, },
) )
relay.OnAuth = append(relay.OnAuth, relay.OnAuth = append(relay.OnAuth,
func(ctx context.Context, pubkey string) { func(ctx context.Context, pubkey string) {
// and when they auth we just log that for nothing // and when they auth we can just log that for nothing
log.Println(pubkey + " is authed!") log.Println(pubkey + " is authed!")
}, },
) )

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.21.0
require ( require (
github.com/fasthttp/websocket v1.5.3 github.com/fasthttp/websocket v1.5.3
github.com/fiatjaf/eventstore v0.1.0 github.com/fiatjaf/eventstore v0.1.0
github.com/nbd-wtf/go-nostr v0.25.7 github.com/nbd-wtf/go-nostr v0.26.0
github.com/puzpuzpuz/xsync/v2 v2.5.1 github.com/puzpuzpuz/xsync/v2 v2.5.1
github.com/rs/cors v1.7.0 github.com/rs/cors v1.7.0
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53

4
go.sum
View File

@@ -90,8 +90,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/nbd-wtf/go-nostr v0.25.7 h1:DcGOSgKVr/L6w62tRtKeV2t46sRyFcq9pWcyIFkh0eM= github.com/nbd-wtf/go-nostr v0.26.0 h1:Tofbs9i8DD5iEKIhLlWFO7kfWpvmUG16fEyW30MzHVQ=
github.com/nbd-wtf/go-nostr v0.25.7/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0= github.com/nbd-wtf/go-nostr v0.26.0/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -14,22 +15,25 @@ import (
"github.com/fasthttp/websocket" "github.com/fasthttp/websocket"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip42" "github.com/nbd-wtf/go-nostr/nip42"
"github.com/rs/cors"
) )
// ServeHTTP implements http.Handler interface. // ServeHTTP implements http.Handler interface.
func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if rl.ServiceURL == "" {
rl.ServiceURL = getServiceBaseURL(r)
}
if r.Header.Get("Upgrade") == "websocket" { if r.Header.Get("Upgrade") == "websocket" {
rl.HandleWebsocket(w, r) rl.HandleWebsocket(w, r)
} else if r.Header.Get("Accept") == "application/nostr+json" { } else if r.Header.Get("Accept") == "application/nostr+json" {
rl.HandleNIP11(w, r) cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
} else { } else {
rl.serveMux.ServeHTTP(w, r) rl.serveMux.ServeHTTP(w, r)
} }
} }
func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) { func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
conn, err := rl.upgrader.Upgrade(w, r, nil) conn, err := rl.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
rl.Log.Printf("failed to upgrade websocket: %v\n", err) rl.Log.Printf("failed to upgrade websocket: %v\n", err)
@@ -43,21 +47,31 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
rand.Read(challenge) rand.Read(challenge)
ws := &WebSocket{ ws := &WebSocket{
conn: conn, conn: conn,
Challenge: hex.EncodeToString(challenge), Request: r,
WaitingForAuth: make(chan struct{}), Challenge: hex.EncodeToString(challenge),
Authed: make(chan struct{}),
}
ctx, cancel := context.WithCancel(
context.WithValue(
context.Background(),
WS_KEY, ws,
),
)
kill := func() {
ticker.Stop()
cancel()
if _, ok := rl.clients.Load(conn); ok {
conn.Close()
rl.clients.Delete(conn)
removeListener(ws)
}
} }
// reader
go func() { go func() {
defer func() { defer kill()
ticker.Stop()
if _, ok := rl.clients.Load(conn); ok {
conn.Close()
rl.clients.Delete(conn)
removeListener(ws)
}
}()
conn.SetReadLimit(rl.MaxMessageSize) conn.SetReadLimit(rl.MaxMessageSize)
conn.SetReadDeadline(time.Now().Add(rl.PongWait)) conn.SetReadDeadline(time.Now().Add(rl.PongWait))
@@ -75,6 +89,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
if websocket.IsUnexpectedCloseError( if websocket.IsUnexpectedCloseError(
err, err,
websocket.CloseNormalClosure, // 1000
websocket.CloseGoingAway, // 1001 websocket.CloseGoingAway, // 1001
websocket.CloseNoStatusReceived, // 1005 websocket.CloseNoStatusReceived, // 1005
websocket.CloseAbnormalClosure, // 1006 websocket.CloseAbnormalClosure, // 1006
@@ -90,152 +105,113 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
} }
go func(message []byte) { go func(message []byte) {
ctx = context.Background() envelope := nostr.ParseMessage(message)
if envelope == nil {
var request []json.RawMessage
if err := json.Unmarshal(message, &request); err != nil {
// stop silently // stop silently
return return
} }
if len(request) < 2 { switch env := envelope.(type) {
ws.WriteJSON(nostr.NoticeEnvelope("request has less than 2 parameters")) case *nostr.EventEnvelope:
return // check id
} hash := sha256.Sum256(env.Event.Serialize())
id := hex.EncodeToString(hash[:])
var typ string if id != env.Event.ID {
json.Unmarshal(request[0], &typ) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: id is computed incorrectly"})
switch typ {
case "EVENT":
// it's a new event
var evt nostr.Event
if err := json.Unmarshal(request[1], &evt); err != nil {
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode event: " + err.Error()))
return return
} }
// check serialization // check signature
serialized := evt.Serialize() if ok, err := env.Event.CheckSignature(); err != nil {
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to verify signature"})
// assign ID
hash := sha256.Sum256(serialized)
evt.ID = hex.EncodeToString(hash[:])
// check signature (requires the ID to be set)
if ok, err := evt.CheckSignature(); err != nil {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to verify signature"})
return return
} else if !ok { } else if !ok {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "invalid: signature is invalid"}) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: signature is invalid"})
return return
} }
var ok bool var ok bool
if evt.Kind == 5 { if env.Event.Kind == 5 {
err = rl.handleDeleteRequest(ctx, &evt) err = rl.handleDeleteRequest(ctx, &env.Event)
} else { } else {
err = rl.AddEvent(ctx, &evt) err = rl.AddEvent(ctx, &env.Event)
} }
var reason string var reason string
if err == nil { if err == nil {
ok = true ok = true
} else { } else {
reason = err.Error() reason = nostr.NormalizeOKMessage(err.Error(), "blocked")
if isAuthRequired(reason) {
ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge})
}
} }
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: ok, Reason: reason}) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: ok, Reason: reason})
case "COUNT": case *nostr.CountEnvelope:
if rl.CountEvents == nil { if rl.CountEvents == nil {
ws.WriteJSON(nostr.NoticeEnvelope("this relay does not support NIP-45")) ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: "unsupported: this relay does not support NIP-45"})
return return
} }
var id string
json.Unmarshal(request[1], &id)
if id == "" {
ws.WriteJSON(nostr.NoticeEnvelope("COUNT has no <id>"))
return
}
var total int64 var total int64
filters := make(nostr.Filters, len(request)-2) for _, filter := range env.Filters {
for i, filterReq := range request[2:] { total += rl.handleCountRequest(ctx, ws, filter)
if err := json.Unmarshal(filterReq, &filters[i]); err != nil {
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode filter"))
continue
}
total += rl.handleCountRequest(ctx, ws, filters[i])
} }
ws.WriteJSON(nostr.CountEnvelope{SubscriptionID: env.SubscriptionID, Count: &total})
ws.WriteJSON([]interface{}{"COUNT", id, map[string]int64{"count": total}}) case *nostr.ReqEnvelope:
case "REQ":
var id string
json.Unmarshal(request[1], &id)
if id == "" {
ws.WriteJSON(nostr.NoticeEnvelope("REQ has no <id>"))
return
}
filters := make(nostr.Filters, len(request)-2)
eose := sync.WaitGroup{} eose := sync.WaitGroup{}
eose.Add(len(request[2:])) eose.Add(len(env.Filters))
for i, filterReq := range request[2:] { // a context just for the "stored events" request handler
if err := json.Unmarshal(filterReq, &filters[i]); err != nil { reqCtx, cancelReqCtx := context.WithCancelCause(ctx)
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode filter"))
eose.Done() // handle each filter separately -- dispatching events as they're loaded from databases
continue for _, filter := range env.Filters {
err := rl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
if err != nil {
// fail everything if any filter is rejected
reason := nostr.NormalizeOKMessage(err.Error(), "blocked")
if isAuthRequired(reason) {
ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge})
}
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: reason})
cancelReqCtx(fmt.Errorf("filter rejected"))
return
} }
go rl.handleRequest(ctx, id, &eose, ws, filters[i])
} }
go func() { go func() {
// when all events have been loaded from databases and dispatched
// we can cancel the context and fire the EOSE message
eose.Wait() eose.Wait()
ws.WriteJSON(nostr.EOSEEnvelope(id)) cancelReqCtx(nil)
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
}() }()
setListener(id, ws, filters) setListener(env.SubscriptionID, ws, env.Filters, cancelReqCtx)
case "CLOSE": case *nostr.CloseEnvelope:
var id string removeListenerId(ws, string(*env))
json.Unmarshal(request[1], &id) case *nostr.AuthEnvelope:
if id == "" { wsBaseUrl := strings.Replace(rl.ServiceURL, "http", "ws", 1)
ws.WriteJSON(nostr.NoticeEnvelope("CLOSE has no <id>")) if pubkey, ok := nip42.ValidateAuthEvent(&env.Event, ws.Challenge, wsBaseUrl); ok {
return ws.AuthedPublicKey = pubkey
} close(ws.Authed)
ctx = context.WithValue(ctx, AUTH_CONTEXT_KEY, pubkey)
removeListenerId(ws, id) ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true})
case "AUTH": } else {
if rl.ServiceURL != "" { ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to authenticate"})
var evt nostr.Event
if err := json.Unmarshal(request[1], &evt); err != nil {
ws.WriteJSON(nostr.NoticeEnvelope("failed to decode auth event: " + err.Error()))
return
}
if pubkey, ok := nip42.ValidateAuthEvent(&evt, ws.Challenge, rl.ServiceURL); ok {
ws.Authed = pubkey
close(ws.WaitingForAuth)
ctx = context.WithValue(ctx, AUTH_CONTEXT_KEY, pubkey)
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: true})
} else {
ws.WriteJSON(nostr.OKEnvelope{EventID: evt.ID, OK: false, Reason: "error: failed to authenticate"})
}
} }
} }
}(message) }(message)
} }
}() }()
// writer
go func() { go func() {
defer func() { defer kill()
ticker.Stop()
conn.Close()
}()
for { for {
select { select {
case <-ctx.Done():
return
case <-ticker.C: case <-ticker.C:
err := ws.WriteMessage(websocket.PingMessage, nil) err := ws.WriteMessage(websocket.PingMessage, nil)
if err != nil { if err != nil {
@@ -251,5 +227,11 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) { func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/nostr+json") w.Header().Set("Content-Type", "application/nostr+json")
json.NewEncoder(w).Encode(rl.Info)
info := *rl.Info
for _, ovw := range rl.OverwriteRelayInformation {
info = ovw(r.Context(), r, info)
}
json.NewEncoder(w).Encode(info)
} }

55
helpers.go Normal file
View File

@@ -0,0 +1,55 @@
package khatru
import (
"hash/maphash"
"net/http"
"regexp"
"strconv"
"strings"
"unsafe"
"github.com/nbd-wtf/go-nostr"
)
const (
AUTH_CONTEXT_KEY = iota
WS_KEY
)
var nip20prefixmatcher = regexp.MustCompile(`^\w+: `)
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 {
return uint64(uintptr(unsafe.Pointer(k)))
}
func isOlder(previous, next *nostr.Event) bool {
return previous.CreatedAt < next.CreatedAt ||
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
}
func isAuthRequired(msg string) bool {
idx := strings.IndexByte(msg, ':')
return msg[0:idx] == "auth-required"
}
func getServiceBaseURL(r *http.Request) string {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if host == "localhost" {
proto = "http"
} else if strings.Index(host, ":") != -1 {
// has a port number
proto = "http"
} else if _, err := strconv.Atoi(strings.ReplaceAll(host, ".", "")); err == nil {
// it's a naked IP
proto = "http"
} else {
proto = "https"
}
}
return proto + "://" + host
}

View File

@@ -1,12 +1,16 @@
package khatru package khatru
import ( import (
"context"
"fmt"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/puzpuzpuz/xsync/v2" "github.com/puzpuzpuz/xsync/v2"
) )
type Listener struct { type Listener struct {
filters nostr.Filters filters nostr.Filters
cancel context.CancelCauseFunc
} }
var listeners = xsync.NewTypedMapOf[*WebSocket, *xsync.MapOf[string, *Listener]](pointerHasher[WebSocket]) var listeners = xsync.NewTypedMapOf[*WebSocket, *xsync.MapOf[string, *Listener]](pointerHasher[WebSocket])
@@ -43,24 +47,28 @@ func GetListeningFilters() nostr.Filters {
return respfilters return respfilters
} }
func setListener(id string, ws *WebSocket, filters nostr.Filters) { func setListener(id string, ws *WebSocket, filters nostr.Filters, cancel context.CancelCauseFunc) {
subs, _ := listeners.LoadOrCompute(ws, func() *xsync.MapOf[string, *Listener] { subs, _ := listeners.LoadOrCompute(ws, func() *xsync.MapOf[string, *Listener] {
return xsync.NewMapOf[*Listener]() return xsync.NewMapOf[*Listener]()
}) })
subs.Store(id, &Listener{filters: filters}) subs.Store(id, &Listener{filters: filters, cancel: cancel})
} }
// Remove a specific subscription id from listeners for a given ws client // remove a specific subscription id from listeners for a given ws client
// and cancel its specific context
func removeListenerId(ws *WebSocket, id string) { func removeListenerId(ws *WebSocket, id string) {
if subs, ok := listeners.Load(ws); ok { if subs, ok := listeners.Load(ws); ok {
subs.Delete(id) if listener, ok := subs.LoadAndDelete(id); ok {
listener.cancel(fmt.Errorf("subscription closed by client"))
}
if subs.Size() == 0 { if subs.Size() == 0 {
listeners.Delete(ws) listeners.Delete(ws)
} }
} }
} }
// Remove WebSocket conn from listeners // remove WebSocket conn from listeners
// (no need to cancel contexts as they are all inherited from the main connection context)
func removeListener(ws *WebSocket) { func removeListener(ws *WebSocket) {
listeners.Delete(ws) listeners.Delete(ws)
} }

View File

@@ -1,15 +1,37 @@
package plugins package policies
import ( import (
"context" "context"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
) )
// PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject // PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject
// events with more indexable (single-character) tags than the specified number. // events with more indexable (single-character) tags than the specified number.
func PreventTooManyIndexableTags(max int) func(context.Context, *nostr.Event) (bool, string) { //
// If ignoreKinds is given this restriction will not apply to these kinds (useful for allowing a bigger).
// If onlyKinds is given then all other kinds will be ignored.
func PreventTooManyIndexableTags(max int, ignoreKinds []int, onlyKinds []int) func(context.Context, *nostr.Event) (bool, string) {
ignore := func(kind int) bool { return false }
if len(ignoreKinds) > 0 {
ignore = func(kind int) bool {
_, isIgnored := slices.BinarySearch(ignoreKinds, kind)
return isIgnored
}
}
if len(onlyKinds) > 0 {
ignore = func(kind int) bool {
_, isApplicable := slices.BinarySearch(onlyKinds, kind)
return !isApplicable
}
}
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) { return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if ignore(event.Kind) {
return false, ""
}
ntags := 0 ntags := 0
for _, tag := range event.Tags { for _, tag := range event.Tags {
if len(tag) > 0 && len(tag[0]) == 1 { if len(tag) > 0 && len(tag[0]) == 1 {
@@ -42,9 +64,7 @@ func PreventLargeTags(maxTagValueLen int) func(context.Context, *nostr.Event) (b
func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) { func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) {
max := 0 max := 0
min := 0 min := 0
allowed := make(map[uint16]struct{}, len(kinds))
for _, kind := range kinds { for _, kind := range kinds {
allowed[kind] = struct{}{}
if int(kind) > max { if int(kind) > max {
max = int(kind) max = int(kind)
} }
@@ -64,7 +84,7 @@ func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Even
} }
// hopefully this map of uint16s is very fast // hopefully this map of uint16s is very fast
if _, allowed := allowed[uint16(event.Kind)]; allowed { if _, allowed := slices.BinarySearch(kinds, uint16(event.Kind)); allowed {
return false, "" return false, ""
} }
return true, "event kind not allowed" return true, "event kind not allowed"

View File

@@ -1,4 +1,4 @@
package plugins package policies
import ( import (
"context" "context"
@@ -7,6 +7,7 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
// NoComplexFilters disallows filters with more than 2 tags.
func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
items := len(filter.Tags) + len(filter.Kinds) items := len(filter.Tags) + len(filter.Kinds)
@@ -17,6 +18,7 @@ func NoComplexFilters(ctx context.Context, filter nostr.Filter) (reject bool, ms
return false, "" return false, ""
} }
// NoEmptyFilters disallows filters that don't have at least a tag, a kind, an author or an id.
func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
c := len(filter.Kinds) + len(filter.IDs) + len(filter.Authors) c := len(filter.Kinds) + len(filter.IDs) + len(filter.Authors)
for _, tagItems := range filter.Tags { for _, tagItems := range filter.Tags {
@@ -28,6 +30,13 @@ func NoEmptyFilters(ctx context.Context, filter nostr.Filter) (reject bool, msg
return false, "" return false, ""
} }
// AntiSyncBots tries to prevent people from syncing kind:1s from this relay to else by always
// requiring an author parameter at least.
func AntiSyncBots(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
return (len(filter.Kinds) == 0 || slices.Contains(filter.Kinds, 1)) &&
len(filter.Authors) == 0, "an author must be specified to get their kind:1 notes"
}
func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if filter.Search != "" { if filter.Search != "" {
return true, "search is not supported" return true, "search is not supported"

View File

@@ -1,4 +1,4 @@
package plugins package policies
import ( import (
"context" "context"
@@ -8,7 +8,8 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
func rejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, string) { // RejectKind04Snoopers prevents reading NIP-04 messages from people not involved in the conversation.
func RejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, string) {
// prevent kind-4 events from being returned to unauthed users, // prevent kind-4 events from being returned to unauthed users,
// only when authentication is a thing // only when authentication is a thing
if !slices.Contains(filter.Kinds, 4) { if !slices.Contains(filter.Kinds, 4) {
@@ -19,13 +20,13 @@ func rejectKind04Snoopers(ctx context.Context, filter nostr.Filter) (bool, strin
senders := filter.Authors senders := filter.Authors
receivers, _ := filter.Tags["p"] receivers, _ := filter.Tags["p"]
switch { switch {
case ws.Authed == "": case ws.AuthedPublicKey == "":
// not authenticated // not authenticated
return true, "restricted: this relay does not serve kind-4 to unauthenticated users, does your client implement NIP-42?" return true, "restricted: this relay does not serve kind-4 to unauthenticated users, does your client implement NIP-42?"
case len(senders) == 1 && len(receivers) < 2 && (senders[0] == ws.Authed): case len(senders) == 1 && len(receivers) < 2 && (senders[0] == ws.AuthedPublicKey):
// allowed filter: ws.authed is sole sender (filter specifies one or all receivers) // allowed filter: ws.authed is sole sender (filter specifies one or all receivers)
return false, "" return false, ""
case len(receivers) == 1 && len(senders) < 2 && (receivers[0] == ws.Authed): case len(receivers) == 1 && len(senders) < 2 && (receivers[0] == ws.AuthedPublicKey):
// allowed filter: ws.authed is sole receiver (filter specifies one or all senders) // allowed filter: ws.authed is sole receiver (filter specifies one or all senders)
return false, "" return false, ""
default: default:

View File

@@ -18,8 +18,9 @@ func NewRelay() *Relay {
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags), Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
Info: &nip11.RelayInformationDocument{ Info: &nip11.RelayInformationDocument{
Software: "https://github.com/fiatjaf/khatru", Software: "https://github.com/fiatjaf/khatru",
Version: "n/a", Version: "n/a",
SupportedNIPs: make([]int, 0),
}, },
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
@@ -39,22 +40,23 @@ func NewRelay() *Relay {
} }
type Relay struct { type Relay struct {
ServiceURL string // required for nip-42 ServiceURL string
RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string) RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string)
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string) OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event) OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter) OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)
OverwriteCountFilter []func(ctx context.Context, filter *nostr.Filter) OverwriteCountFilter []func(ctx context.Context, filter *nostr.Filter)
StoreEvent []func(ctx context.Context, event *nostr.Event) error OverwriteRelayInformation []func(ctx context.Context, r *http.Request, info nip11.RelayInformationDocument) nip11.RelayInformationDocument
DeleteEvent []func(ctx context.Context, event *nostr.Event) error StoreEvent []func(ctx context.Context, event *nostr.Event) error
QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) DeleteEvent []func(ctx context.Context, event *nostr.Event) error
CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error) QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
OnAuth []func(ctx context.Context, pubkey string) CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error)
OnConnect []func(ctx context.Context) OnAuth []func(ctx context.Context, pubkey string)
OnEventSaved []func(ctx context.Context, event *nostr.Event) OnConnect []func(ctx context.Context)
OnEventSaved []func(ctx context.Context, event *nostr.Event)
// editing info will affect // editing info will affect
Info *nip11.RelayInformationDocument Info *nip11.RelayInformationDocument
@@ -80,8 +82,3 @@ type Relay struct {
PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait. PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait.
MaxMessageSize int64 // Maximum message size allowed from peer. MaxMessageSize int64 // Maximum message size allowed from peer.
} }
func (rl *Relay) RequestAuth(ctx context.Context) {
ws := GetConnection(ctx)
ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge})
}

View File

@@ -2,12 +2,13 @@ package khatru
import ( import (
"context" "context"
"fmt"
"sync" "sync"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
) )
func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) { func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGroup, ws *WebSocket, filter nostr.Filter) error {
defer eose.Done() defer eose.Done()
// overwrite the filter (for example, to eliminate some kinds or // overwrite the filter (for example, to eliminate some kinds or
@@ -17,7 +18,7 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
} }
if filter.Limit < 0 { if filter.Limit < 0 {
return return fmt.Errorf("filter invalidated")
} }
// then check if we'll reject this filter (we apply this after overwriting // then check if we'll reject this filter (we apply this after overwriting
@@ -27,7 +28,7 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
for _, reject := range rl.RejectFilter { for _, reject := range rl.RejectFilter {
if reject, msg := reject(ctx, filter); reject { if reject, msg := reject(ctx, filter); reject {
ws.WriteJSON(nostr.NoticeEnvelope(msg)) ws.WriteJSON(nostr.NoticeEnvelope(msg))
return return fmt.Errorf(msg)
} }
} }
@@ -52,6 +53,8 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
eose.Done() eose.Done()
}(ch) }(ch)
} }
return nil
} }
func (rl *Relay) handleCountRequest(ctx context.Context, ws *WebSocket, filter nostr.Filter) int64 { func (rl *Relay) handleCountRequest(ctx context.Context, ws *WebSocket, filter nostr.Filter) int64 {

View File

@@ -2,20 +2,8 @@ package khatru
import ( import (
"context" "context"
"hash/maphash"
"regexp"
"unsafe"
"github.com/nbd-wtf/go-nostr"
) )
const (
AUTH_CONTEXT_KEY = iota
WS_KEY = iota
)
var nip20prefixmatcher = regexp.MustCompile(`^\w+: `)
func GetConnection(ctx context.Context) *WebSocket { func GetConnection(ctx context.Context) *WebSocket {
return ctx.Value(WS_KEY).(*WebSocket) return ctx.Value(WS_KEY).(*WebSocket)
} }
@@ -27,12 +15,3 @@ func GetAuthed(ctx context.Context) string {
} }
return authedPubkey.(string) return authedPubkey.(string)
} }
func pointerHasher[V any](_ maphash.Seed, k *V) uint64 {
return uint64(uintptr(unsafe.Pointer(k)))
}
func isOlder(previous, next *nostr.Event) bool {
return previous.CreatedAt < next.CreatedAt ||
(previous.CreatedAt == next.CreatedAt && previous.ID > next.ID)
}

View File

@@ -1,6 +1,7 @@
package khatru package khatru
import ( import (
"net/http"
"sync" "sync"
"github.com/fasthttp/websocket" "github.com/fasthttp/websocket"
@@ -10,10 +11,13 @@ type WebSocket struct {
conn *websocket.Conn conn *websocket.Conn
mutex sync.Mutex mutex sync.Mutex
// original request
Request *http.Request
// nip42 // nip42
Challenge string Challenge string
Authed string AuthedPublicKey string
WaitingForAuth chan struct{} Authed chan struct{}
} }
func (ws *WebSocket) WriteJSON(any any) error { func (ws *WebSocket) WriteJSON(any any) error {