Compare commits

..

1 Commits

Author SHA1 Message Date
fiatjaf
8df7c9d773 NewRelayWithContext() and Close(). 2024-02-19 06:33:49 -03:00
20 changed files with 135 additions and 639 deletions

View File

@@ -69,11 +69,6 @@ func main() {
// there are many other configurable things you can set
relay.RejectEvent = append(relay.RejectEvent,
// built-in policies
policies.ValidateKind,
// define your own policies
policies.PreventLargeTags(80),
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
return true, "we don't allow this person to write here"
@@ -84,10 +79,6 @@ func main() {
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
relay.RejectFilter = append(relay.RejectFilter,
// built-in policies
policies.NoComplexFilters,
// define your own policies
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
log.Printf("request from %s\n", pubkey)
@@ -128,13 +119,3 @@ Fear no more. Using the https://github.com/fiatjaf/eventstore module you get a b
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
```
### But I don't want to write a bunch of custom policies!
Fear no more. We have a bunch of common policies written in the `github.com/fiatjaf/khatru/policies` package and also a handpicked selection of base sane defaults, which you can apply with:
```go
policies.ApplySaneDefaults(relay)
```
Contributions to this are very much welcomed.

View File

@@ -10,17 +10,17 @@ import (
)
// AddEvent sends an event through then normal add pipeline, as if it was received from a websocket.
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
if evt == nil {
return false, errors.New("error: event is nil")
return errors.New("error: event is nil")
}
for _, reject := range rl.RejectEvent {
if reject, msg := reject(ctx, evt); reject {
if msg == "" {
return false, errors.New("blocked: no reason")
return errors.New("blocked: no reason")
} else {
return false, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
}
@@ -31,53 +31,29 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast
oee(ctx, evt)
}
} else {
// will store
// but first check if we already have it
filter := nostr.Filter{IDs: []string{evt.ID}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for range ch {
// if we run this it means we already have this event, so we just return a success and exit
return true, nil
}
}
// if it's replaceable we first delete old versions
if evt.Kind == 0 || evt.Kind == 3 || (10000 <= evt.Kind && evt.Kind < 20000) {
// replaceable event, delete before storing
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}})
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
}
}
} else if 30000 <= evt.Kind && evt.Kind < 40000 {
// parameterized replaceable event, delete before storing
d := evt.Tags.GetFirst([]string{"d", ""})
if d == nil {
return false, fmt.Errorf("invalid: missing 'd' tag on parameterized replaceable event")
}
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{(*d)[1]}}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
if d != nil {
for _, query := range rl.QueryEvents {
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{d.Value()}}})
if err != nil {
continue
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
@@ -91,9 +67,9 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast
if saveErr := store(ctx, evt); saveErr != nil {
switch saveErr {
case eventstore.ErrDupEvent:
return true, nil
return nil
default:
return false, fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
return fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
}
}
}
@@ -103,5 +79,5 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast
}
}
return false, nil
return nil
}

View File

@@ -34,9 +34,7 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
if acceptDeletion {
// delete it
for _, del := range rl.DeleteEvent {
if err := del(ctx, target); err != nil {
return err
}
del(ctx, target)
}
} else {
// fail and stop here

View File

@@ -7,7 +7,6 @@ import (
"net/http"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
"github.com/nbd-wtf/go-nostr"
)
@@ -54,11 +53,6 @@ func main() {
// there are many other configurable things you can set
relay.RejectEvent = append(relay.RejectEvent,
// built-in policies
policies.ValidateKind,
// define your own policies
policies.PreventLargeTags(80),
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
return true, "we don't allow this person to write here"
@@ -69,10 +63,6 @@ func main() {
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
relay.RejectFilter = append(relay.RejectFilter,
// built-in policies
policies.NoComplexFilters,
// define your own policies
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
log.Printf("request from %s\n", pubkey)

9
go.mod
View File

@@ -4,10 +4,11 @@ go 1.21.4
require (
github.com/fasthttp/websocket v1.5.7
github.com/fiatjaf/eventstore v0.5.1
github.com/nbd-wtf/go-nostr v0.34.3
github.com/fiatjaf/eventstore v0.3.8
github.com/nbd-wtf/go-nostr v0.28.1
github.com/puzpuzpuz/xsync/v3 v3.0.2
github.com/rs/cors v1.7.0
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
)
require (
@@ -37,7 +38,7 @@ require (
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/compress v1.17.3 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
@@ -51,6 +52,6 @@ require (
go.opencensus.io v0.24.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/sys v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

22
go.sum
View File

@@ -47,8 +47,8 @@ github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KH
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fiatjaf/eventstore v0.5.1 h1:tTh+JYP0RME51VY2QB2Gvtzj6QTaZAnSVhgZtrYqY2A=
github.com/fiatjaf/eventstore v0.5.1/go.mod h1:r5yCFmrVNT2b1xUOuMnDVS3xPGh97y8IgTcLyY2rYP8=
github.com/fiatjaf/eventstore v0.3.8 h1:q4jcN95O2CVA+wP47V25BcVSNvjfOiPPIWgPmQ6hTRk=
github.com/fiatjaf/eventstore v0.3.8/go.mod h1:Qsm5loQICkazpsj8tQmcOK95AVkQQNF09Xx/NS/Biow=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -103,8 +103,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -113,8 +113,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.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/nbd-wtf/go-nostr v0.34.3 h1:JfDOHOje7gzUhisbZD0v2Y9b9vh2PmP6eHsU/GfU8QE=
github.com/nbd-wtf/go-nostr v0.34.3/go.mod h1:NZQkxl96ggbO8rvDpVjcsojJqKTPwqhP4i82O7K5DJs=
github.com/nbd-wtf/go-nostr v0.28.1 h1:XQi/lBsigBXHRm7IDBJE7SR9citCh9srgf8sA5iVW3A=
github.com/nbd-wtf/go-nostr v0.28.1/go.mod h1:OQ8sNLFJnsj17BdqZiLSmjJBIFTfDqckEYC3utS4qoY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -126,6 +126,8 @@ github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -133,8 +135,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -184,8 +186,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -27,21 +28,12 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rl.HandleWebsocket(w, r)
} else if r.Header.Get("Accept") == "application/nostr+json" {
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
} else if r.Header.Get("Content-Type") == "application/nostr+json+rpc" {
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r)
} else {
rl.serveMux.ServeHTTP(w, r)
}
}
func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
for _, reject := range rl.RejectConnection {
if reject(r) {
w.WriteHeader(429) // Too many requests
return
}
}
conn, err := rl.upgrader.Upgrade(w, r, nil)
if err != nil {
rl.Log.Printf("failed to upgrade websocket: %v\n", err)
@@ -169,13 +161,12 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var ok bool
var writeErr error
var skipBroadcast bool
if env.Event.Kind == 5 {
// this always returns "blocked: " whenever it returns an error
writeErr = rl.handleDeleteRequest(ctx, &env.Event)
} else {
// this will also always return a prefixed reason
skipBroadcast, writeErr = rl.AddEvent(ctx, &env.Event)
writeErr = rl.AddEvent(ctx, &env.Event)
}
var reason string
@@ -184,9 +175,7 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
for _, ovw := range rl.OverwriteResponseEvent {
ovw(ctx, &env.Event)
}
if !skipBroadcast {
notifyListeners(&env.Event)
}
notifyListeners(&env.Event)
} else {
reason = writeErr.Error()
if strings.HasPrefix(reason, "auth-required:") {
@@ -278,3 +267,15 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
}
}()
}
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/nostr+json")
info := *rl.Info
for _, ovw := range rl.OverwriteRelayInformation {
info = ovw(r.Context(), r, info)
}
json.NewEncoder(w).Encode(info)
}

View File

@@ -1,7 +1,6 @@
package khatru
import (
"net"
"net/http"
"strconv"
"strings"
@@ -35,43 +34,3 @@ func getServiceBaseURL(r *http.Request) string {
}
return proto + "://" + host
}
var privateMasks = func() []net.IPNet {
privateCIDRs := []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
}
masks := make([]net.IPNet, len(privateCIDRs))
for i, cidr := range privateCIDRs {
_, netw, err := net.ParseCIDR(cidr)
if err != nil {
return nil
}
masks[i] = *netw
}
return masks
}()
func isPrivate(ip net.IP) bool {
for _, mask := range privateMasks {
if mask.Contains(ip) {
return true
}
}
return false
}
func GetIPFromRequest(r *http.Request) string {
if xffh := r.Header.Get("X-Forwarded-For"); xffh != "" {
for _, v := range strings.Split(xffh, ",") {
if ip := net.ParseIP(strings.TrimSpace(v)); ip != nil && ip.IsGlobalUnicast() && !isPrivate(ip) {
return ip.String()
}
}
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}

View File

@@ -1,25 +0,0 @@
package khatru
import (
"encoding/json"
"net/http"
)
func (rl *Relay) HandleNIP11(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/nostr+json")
info := *rl.Info
if len(rl.DeleteEvent) > 0 {
info.SupportedNIPs = append(info.SupportedNIPs, 9)
}
if len(rl.CountEvents) > 0 {
info.SupportedNIPs = append(info.SupportedNIPs, 45)
}
for _, ovw := range rl.OverwriteRelayInformation {
info = ovw(r.Context(), r, info)
}
json.NewEncoder(w).Encode(info)
}

267
nip86.go
View File

@@ -1,267 +0,0 @@
package khatru
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"reflect"
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip86"
)
type RelayManagementAPI struct {
RejectAPICall []func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string)
BanPubKey func(ctx context.Context, pubkey string, reason string) error
ListBannedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
AllowPubKey func(ctx context.Context, pubkey string, reason string) error
ListAllowedPubKeys func(ctx context.Context) ([]nip86.PubKeyReason, error)
ListEventsNeedingModeration func(ctx context.Context) ([]nip86.IDReason, error)
AllowEvent func(ctx context.Context, id string, reason string) error
BanEvent func(ctx context.Context, id string, reason string) error
ListBannedEvents func(ctx context.Context) ([]nip86.IDReason, error)
ChangeRelayName func(ctx context.Context, name string) error
ChangeRelayDescription func(ctx context.Context, desc string) error
ChangeRelayIcon func(ctx context.Context, icon string) error
AllowKind func(ctx context.Context, kind int) error
DisallowKind func(ctx context.Context, kind int) error
ListAllowedKinds func(ctx context.Context) ([]int, error)
BlockIP func(ctx context.Context, ip net.IP, reason string) error
UnblockIP func(ctx context.Context, ip net.IP, reason string) error
ListBlockedIPs func(ctx context.Context) ([]nip86.IPReason, error)
}
func (rl *Relay) HandleNIP86(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/nostr+json+rpc")
var (
resp nip86.Response
ctx = r.Context()
req nip86.Request
mp nip86.MethodParams
evt nostr.Event
payloadHash [32]byte
)
payload, err := io.ReadAll(r.Body)
if err != nil {
resp.Error = "empty request"
goto respond
}
payloadHash = sha256.Sum256(payload)
{
auth := r.Header.Get("Authorization")
spl := strings.Split(auth, "Nostr ")
if len(spl) != 2 {
resp.Error = "missing auth"
goto respond
}
if evtj, err := base64.StdEncoding.DecodeString(spl[1]); err != nil {
resp.Error = "invalid base64 auth"
goto respond
} else if err := json.Unmarshal(evtj, &evt); err != nil {
resp.Error = "invalid auth event json"
goto respond
} else if ok, _ := evt.CheckSignature(); !ok {
resp.Error = "invalid auth event"
goto respond
} else if pht := evt.Tags.GetFirst([]string{"payload", hex.EncodeToString(payloadHash[:])}); pht == nil {
resp.Error = "invalid auth event payload hash"
goto respond
} else if evt.CreatedAt < nostr.Now()-30 {
resp.Error = "auth event is too old"
goto respond
}
}
if err := json.Unmarshal(payload, &req); err != nil {
resp.Error = "invalid json body"
goto respond
}
mp, err = nip86.DecodeRequest(req)
if err != nil {
resp.Error = fmt.Sprintf("invalid params: %s", err)
goto respond
}
ctx = context.WithValue(ctx, nip86HeaderAuthKey, evt.PubKey)
for _, rac := range rl.ManagementAPI.RejectAPICall {
if reject, msg := rac(ctx, mp); reject {
resp.Error = msg
goto respond
}
}
if _, ok := mp.(nip86.SupportedMethods); ok {
mat := reflect.TypeOf(rl.ManagementAPI)
mav := reflect.ValueOf(rl.ManagementAPI)
methods := make([]string, 0, mat.NumField())
for i := 0; i < mat.NumField(); i++ {
field := mat.Field(i)
// danger: this assumes the struct fields are appropriately named
methodName := strings.ToLower(field.Name)
// assign this only if the function was defined
if mav.Field(i).Interface() != nil {
methods[i] = methodName
}
}
resp.Result = methods
} else {
switch thing := mp.(type) {
case nip86.BanPubKey:
if rl.ManagementAPI.BanPubKey == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.BanPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListBannedPubKeys:
if rl.ManagementAPI.ListBannedPubKeys == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListBannedPubKeys(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.AllowPubKey:
if rl.ManagementAPI.AllowPubKey == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.AllowPubKey(ctx, thing.PubKey, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListAllowedPubKeys:
if rl.ManagementAPI.ListAllowedPubKeys == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListAllowedPubKeys(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.BanEvent:
if rl.ManagementAPI.BanEvent == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.BanEvent(ctx, thing.ID, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.AllowEvent:
if rl.ManagementAPI.AllowEvent == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.AllowEvent(ctx, thing.ID, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListEventsNeedingModeration:
if rl.ManagementAPI.ListEventsNeedingModeration == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListEventsNeedingModeration(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.ListBannedEvents:
if rl.ManagementAPI.ListBannedEvents == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListEventsNeedingModeration(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.ChangeRelayName:
if rl.ManagementAPI.ChangeRelayName == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.ChangeRelayName(ctx, thing.Name); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ChangeRelayDescription:
if rl.ManagementAPI.ChangeRelayDescription == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.ChangeRelayDescription(ctx, thing.Description); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ChangeRelayIcon:
if rl.ManagementAPI.ChangeRelayIcon == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.ChangeRelayIcon(ctx, thing.IconURL); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.AllowKind:
if rl.ManagementAPI.AllowKind == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.AllowKind(ctx, thing.Kind); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.DisallowKind:
if rl.ManagementAPI.DisallowKind == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.DisallowKind(ctx, thing.Kind); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListAllowedKinds:
if rl.ManagementAPI.ListAllowedKinds == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListAllowedKinds(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
case nip86.BlockIP:
if rl.ManagementAPI.BlockIP == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.BlockIP(ctx, thing.IP, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.UnblockIP:
if rl.ManagementAPI.UnblockIP == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if err := rl.ManagementAPI.UnblockIP(ctx, thing.IP, thing.Reason); err != nil {
resp.Error = err.Error()
} else {
resp.Result = true
}
case nip86.ListBlockedIPs:
if rl.ManagementAPI.ListBlockedIPs == nil {
resp.Error = fmt.Sprintf("method %s not supported", thing.MethodName())
} else if result, err := rl.ManagementAPI.ListBlockedIPs(ctx); err != nil {
resp.Error = err.Error()
} else {
resp.Result = result
}
default:
resp.Error = fmt.Sprintf("method '%s' not known", mp.MethodName())
}
}
respond:
json.NewEncoder(w).Encode(resp)
}

View File

@@ -2,9 +2,8 @@ package policies
import (
"context"
"fmt"
"slices"
"strings"
"github.com/nbd-wtf/go-nostr"
)
@@ -15,9 +14,6 @@ import (
// 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) {
slices.Sort(ignoreKinds)
slices.Sort(onlyKinds)
ignore := func(kind int) bool { return false }
if len(ignoreKinds) > 0 {
ignore = func(kind int) bool {
@@ -67,15 +63,32 @@ func PreventLargeTags(maxTagValueLen int) func(context.Context, *nostr.Event) (b
// RestrictToSpecifiedKinds returns a function that can be used as a RejectFilter that will reject
// any events with kinds different than the specified ones.
func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) {
// sort the kinds in increasing order
slices.Sort(kinds)
max := 0
min := 0
for _, kind := range kinds {
if int(kind) > max {
max = int(kind)
}
if int(kind) < min {
min = int(kind)
}
}
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// these are cheap and very questionable optimizations, but they exist for a reason:
// we would have to ensure that the kind number is within the bounds of a uint16 anyway
if event.Kind > max {
return true, "event kind not allowed"
}
if event.Kind < min {
return true, "event kind not allowed"
}
// hopefully this map of uint16s is very fast
if _, allowed := slices.BinarySearch(kinds, uint16(event.Kind)); allowed {
return false, ""
}
return true, fmt.Sprintf("received event kind %d not allowed", event.Kind)
return true, "event kind not allowed"
}
}
@@ -96,7 +109,3 @@ func PreventTimestampsInTheFuture(thresholdSeconds nostr.Timestamp) func(context
return false, ""
}
}
func RejectEventsWithBase64Media(ctx context.Context, evt *nostr.Event) (bool, string) {
return strings.Contains(evt.Content, "data:image/") || strings.Contains(evt.Content, "data:video/"), "event with base64 media"
}

View File

@@ -2,6 +2,7 @@ package policies
import (
"context"
"slices"
"github.com/nbd-wtf/go-nostr"
@@ -47,7 +48,7 @@ func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg
func RemoveSearchQueries(ctx context.Context, filter *nostr.Filter) {
if filter.Search != "" {
filter.Search = ""
filter.LimitZero = true // signals that this query should be just skipped
filter.Limit = -1 // signals that this query should be just skipped
}
}
@@ -62,7 +63,7 @@ func RemoveAllButKinds(kinds ...uint16) func(context.Context, *nostr.Filter) {
}
filter.Kinds = newKinds
if len(filter.Kinds) == 0 {
filter.LimitZero = true // signals that this query should be just skipped
filter.Limit = -1 // signals that this query should be just skipped
}
}
}
@@ -77,7 +78,7 @@ func RemoveAllButTags(tagNames ...string) func(context.Context, *nostr.Filter) {
}
}
if len(filter.Tags) == 0 {
filter.LimitZero = true // signals that this query should be just skipped
filter.Limit = -1 // signals that this query should be just skipped
}
}
}

View File

@@ -1,43 +0,0 @@
package policies
import (
"sync/atomic"
"time"
"github.com/puzpuzpuz/xsync/v3"
)
func startRateLimitSystem[K comparable](
tokensPerInterval int,
interval time.Duration,
maxTokens int,
) func(key K) (ratelimited bool) {
negativeBuckets := xsync.NewMapOf[K, *atomic.Int32]()
maxTokensInt32 := int32(maxTokens)
go func() {
for {
time.Sleep(interval)
negativeBuckets.Range(func(key K, bucket *atomic.Int32) bool {
newv := bucket.Add(int32(-tokensPerInterval))
if newv <= 0 {
negativeBuckets.Delete(key)
}
return true
})
}
}()
return func(key K) bool {
nb, _ := negativeBuckets.LoadOrStore(key, &atomic.Int32{})
if nb.Load() < maxTokensInt32 {
nb.Add(1)
// rate limit not reached yet
return false
}
// rate limit reached
return true
}
}

View File

@@ -1,29 +0,0 @@
package policies
import (
"context"
"encoding/json"
"github.com/nbd-wtf/go-nostr"
)
func ValidateKind(ctx context.Context, evt *nostr.Event) (bool, string) {
switch evt.Kind {
case 0:
var m struct {
Name string `json:"name"`
}
json.Unmarshal([]byte(evt.Content), &m)
if m.Name == "" {
return true, "missing json name in kind 0"
}
case 1:
return false, ""
case 2:
return true, "this kind has been deprecated"
}
// TODO: all other kinds
return false, ""
}

View File

@@ -1,42 +0,0 @@
package policies
import (
"context"
"net/http"
"time"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
func EventIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
return rl(khatru.GetIP(ctx)), "rate-limited: slow down, please"
}
}
func EventPubKeyRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, evt *nostr.Event) (reject bool, msg string) {
return rl(evt.PubKey), "rate-limited: slow down, please"
}
}
func ConnectionRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(r *http.Request) bool {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(r *http.Request) bool {
return rl(khatru.GetIPFromRequest(r))
}
}
func FilterIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
return rl(khatru.GetIP(ctx)), "rate-limited: there is a bug in the client, no one should be making so many requests"
}
}

View File

@@ -1,24 +0,0 @@
package policies
import (
"time"
"github.com/fiatjaf/khatru"
)
func ApplySaneDefaults(relay *khatru.Relay) {
relay.RejectEvent = append(relay.RejectEvent,
RejectEventsWithBase64Media,
EventIPRateLimiter(2, time.Minute*3, 5),
)
relay.RejectFilter = append(relay.RejectFilter,
NoEmptyFilters,
NoComplexFilters,
FilterIPRateLimiter(20, time.Minute, 100),
)
relay.RejectConnection = append(relay.RejectConnection,
ConnectionRateLimiter(1, time.Minute*5, 3),
)
}

View File

@@ -2,6 +2,7 @@ package khatru
import (
"context"
"fmt"
"log"
"net/http"
"os"
@@ -13,39 +14,12 @@ import (
"github.com/puzpuzpuz/xsync/v3"
)
func NewRelay() *Relay {
return &Relay{
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
Info: &nip11.RelayInformationDocument{
Software: "https://github.com/fiatjaf/khatru",
Version: "n/a",
SupportedNIPs: []int{1, 11, 42, 70, 86},
},
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
},
clients: xsync.NewMapOf[*websocket.Conn, struct{}](),
serveMux: &http.ServeMux{},
WriteWait: 10 * time.Second,
PongWait: 60 * time.Second,
PingPeriod: 30 * time.Second,
MaxMessageSize: 512000,
}
}
type Relay struct {
ServiceURL 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)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectConnection []func(r *http.Request) bool
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)
@@ -60,10 +34,7 @@ type Relay struct {
OnEventSaved []func(ctx context.Context, event *nostr.Event)
OnEphemeralEvent []func(ctx context.Context, event *nostr.Event)
// setting up handlers here will enable these methods
ManagementAPI RelayManagementAPI
// editing info will affect the NIP-11 responses
// editing info will affect the responses to the NIP-11 endpoint
Info *nip11.RelayInformationDocument
// Default logger, as set by NewServer, is a stdlib logger prefixed with "[khatru-relay] ",
@@ -86,4 +57,49 @@ type Relay struct {
PongWait time.Duration // Time allowed to read the next pong message from the peer.
PingPeriod time.Duration // Send pings to peer with this period. Must be less than pongWait.
MaxMessageSize int64 // Maximum message size allowed from peer.
// this context is used for all things inside the relay
Context context.Context
cancel context.CancelCauseFunc
}
func NewRelay() *Relay {
return NewRelayWithContext(context.Background())
}
func NewRelayWithContext(ctx context.Context) *Relay {
ctx, cancel := context.WithCancelCause(ctx)
rl := &Relay{
Log: log.New(os.Stderr, "[khatru-relay] ", log.LstdFlags),
Info: &nip11.RelayInformationDocument{
Software: "https://github.com/fiatjaf/khatru",
Version: "n/a",
SupportedNIPs: []int{1, 11, 70},
},
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
},
clients: xsync.NewMapOf[*websocket.Conn, struct{}](),
serveMux: &http.ServeMux{},
WriteWait: 10 * time.Second,
PongWait: 60 * time.Second,
PingPeriod: 30 * time.Second,
MaxMessageSize: 512000,
Context: ctx,
cancel: cancel,
}
return rl
}
func (rl *Relay) Close() {
rl.cancel(fmt.Errorf("Close called"))
}

View File

@@ -17,8 +17,9 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
ovw(ctx, &filter)
}
if filter.LimitZero {
// don't do any queries, just subscribe to future events
if filter.Limit < 0 {
// this is a special situation through which the implementor signals to us that it doesn't want
// to event perform any queries whatsoever
return nil
}
@@ -28,6 +29,7 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
// filter we can just reject it)
for _, reject := range rl.RejectFilter {
if reject, msg := reject(ctx, filter); reject {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
@@ -41,9 +43,6 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
eose.Done()
continue
} else if ch == nil {
eose.Done()
continue
}
go func(ch chan *nostr.Event) {

View File

@@ -2,6 +2,7 @@ package khatru
import (
"context"
"fmt"
"net"
"net/http"
"strconv"
@@ -48,6 +49,8 @@ func (rl *Relay) Start(host string, port int, started ...chan bool) error {
// Shutdown sends a websocket close control message to all connected clients.
func (rl *Relay) Shutdown(ctx context.Context) {
rl.cancel(fmt.Errorf("Shutdown called"))
rl.httpServer.Shutdown(ctx)
rl.clients.Range(func(conn *websocket.Conn, _ struct{}) bool {

View File

@@ -4,12 +4,12 @@ import (
"context"
"github.com/nbd-wtf/go-nostr"
"github.com/sebest/xff"
)
const (
wsKey = iota
subscriptionIdKey
nip86HeaderAuthKey
)
func RequestAuth(ctx context.Context) {
@@ -23,25 +23,15 @@ func RequestAuth(ctx context.Context) {
}
func GetConnection(ctx context.Context) *WebSocket {
wsi := ctx.Value(wsKey)
if wsi != nil {
return wsi.(*WebSocket)
}
return nil
return ctx.Value(wsKey).(*WebSocket)
}
func GetAuthed(ctx context.Context) string {
if conn := GetConnection(ctx); conn != nil {
return conn.AuthedPublicKey
}
if nip86Auth := ctx.Value(nip86HeaderAuthKey); nip86Auth != nil {
return nip86Auth.(string)
}
return ""
return GetConnection(ctx).AuthedPublicKey
}
func GetIP(ctx context.Context) string {
return GetIPFromRequest(GetConnection(ctx).Request)
return xff.GetRemoteAddr(GetConnection(ctx).Request)
}
func GetSubscriptionID(ctx context.Context) string {