diff --git a/app/config/config.go b/app/config/config.go index d86901d..65ec8d1 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -32,6 +32,8 @@ type C struct { Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation"` IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"` Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"` + Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"` + ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"follows"` } // New creates and initializes a new configuration object for the relay diff --git a/app/handle-delete.go b/app/handle-delete.go index 38a1c23..e466eff 100644 --- a/app/handle-delete.go +++ b/app/handle-delete.go @@ -3,7 +3,6 @@ package app import ( "fmt" - database "database.orly" "database.orly/indexes/types" "encoders.orly/envelopes/eventenvelope" "encoders.orly/event" @@ -21,22 +20,11 @@ import ( func (l *Listener) GetSerialsFromFilter(f *filter.F) ( sers types.Uint40s, err error, ) { - var idxs []database.Range - if idxs, err = database.GetIndexesFromFilter(f); chk.E(err) { - return - } - for _, idx := range idxs { - var s types.Uint40s - if s, err = l.GetSerialsByRange(idx); chk.E(err) { - continue - } - sers = append(sers, s...) - } - return + return l.D.GetSerialsFromFilter(f) } func (l *Listener) HandleDelete(env *eventenvelope.Submission) { - log.T.C( + log.I.C( func() string { return fmt.Sprintf( "delete event\n%s", env.E.Serialize(), diff --git a/app/handle-event.go b/app/handle-event.go index 5278e6d..23424f8 100644 --- a/app/handle-event.go +++ b/app/handle-event.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + acl "acl.orly" "encoders.orly/envelopes/eventenvelope" "encoders.orly/kind" "lol.mleku.dev/chk" @@ -69,9 +70,15 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { } } // store the event + log.I.F("saving event %0x, %s", env.E.ID, env.E.Serialize()) if _, _, err = l.SaveEvent(l.Ctx, env.E); chk.E(err) { return } + // if a follow list was saved, reconfigure ACLs now that it is persisted + if env.E.Kind == kind.FollowList.K { + if err = acl.Registry.Configure(); chk.E(err) { + } + } l.publishers.Deliver(env.E) // Send a success response storing if err = Ok.Ok(l, env, ""); chk.E(err) { diff --git a/go.mod b/go.mod index 6a93528..b034933 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module next.orly.dev go 1.25.0 require ( + acl.orly v0.0.0-00010101000000-000000000000 + crypto.orly v0.0.0-00010101000000-000000000000 database.orly v0.0.0-00010101000000-000000000000 encoders.orly v0.0.0-00010101000000-000000000000 github.com/adrg/xdg v0.5.3 @@ -17,7 +19,6 @@ require ( ) require ( - crypto.orly v0.0.0-00010101000000-000000000000 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect diff --git a/main.go b/main.go index fc6bef8..4d41edb 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" + acl "acl.orly" database "database.orly" "lol.mleku.dev/chk" "lol.mleku.dev/log" @@ -28,6 +29,10 @@ func main() { ); chk.E(err) { os.Exit(1) } + acl.Registry.Active.Store(cfg.ACLMode) + if err = acl.Registry.Configure(cfg, db); chk.E(err) { + os.Exit(1) + } quit := app.Run(ctx, cfg, db) sigs := make(chan os.Signal, 1) signal.Notify(sigs, os.Interrupt) diff --git a/pkg/acl/acl.go b/pkg/acl/acl.go new file mode 100644 index 0000000..3f6428f --- /dev/null +++ b/pkg/acl/acl.go @@ -0,0 +1,59 @@ +package acl + +import ( + "interfaces.orly/acl" + "utils.orly/atomic" +) + +var Registry = &S{} + +type S struct { + ACL []acl.I + Active atomic.String +} + +type A struct{ S } + +func (s *S) Register(i acl.I) { + (*s).ACL = append((*s).ACL, i) +} + +func (s *S) Configure(cfg ...any) (err error) { + for _, i := range s.ACL { + if i.Type() == s.Active.Load() { + err = i.Configure(cfg...) + return + } + } + return err +} + +func (s *S) GetAccessLevel(pub []byte) (level string) { + for _, i := range s.ACL { + if i.Type() == s.Active.Load() { + level = i.GetAccessLevel(pub) + break + } + } + return +} + +func (s *S) GetACLInfo() (name, description, documentation string) { + for _, i := range s.ACL { + if i.Type() == s.Active.Load() { + name, description, documentation = i.GetACLInfo() + break + } + } + return +} + +func (s *S) Type() (typ string) { + for _, i := range s.ACL { + if i.Type() == s.Active.Load() { + typ = i.Type() + break + } + } + return +} diff --git a/pkg/acl/follows.go b/pkg/acl/follows.go new file mode 100644 index 0000000..684a692 --- /dev/null +++ b/pkg/acl/follows.go @@ -0,0 +1,127 @@ +package acl + +import ( + "reflect" + "sync" + + database "database.orly" + "database.orly/indexes/types" + "encoders.orly/bech32encoding" + "encoders.orly/event" + "encoders.orly/filter" + "encoders.orly/hex" + "encoders.orly/kind" + "encoders.orly/tag" + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "lol.mleku.dev/log" + "next.orly.dev/app/config" + utils "utils.orly" +) + +type Follows struct { + cfg *config.C + *database.D + followsMx sync.RWMutex + admins [][]byte + follows [][]byte +} + +func (f *Follows) Configure(cfg ...any) (err error) { + log.I.F("configuring follows ACL") + for _, ca := range cfg { + switch c := ca.(type) { + case *config.C: + log.D.F("setting ACL config: %v", c) + f.cfg = c + case *database.D: + log.D.F("setting ACL database: %s", c.Path()) + f.D = c + default: + err = errorf.E("invalid type: %T", reflect.TypeOf(ca)) + } + } + if f.cfg == nil || f.D == nil { + err = errorf.E("both config and database must be set") + return + } + // find admin follow lists + f.followsMx.Lock() + defer f.followsMx.Unlock() + log.I.F("finding admins") + f.follows, f.admins = nil, nil + for _, admin := range f.cfg.Admins { + log.I.F("%s", admin) + var adm []byte + if adm, err = bech32encoding.NpubOrHexToPublicKeyBinary(admin); chk.E(err) { + continue + } + log.I.F("admin: %0x", adm) + f.admins = append(f.admins, adm) + fl := &filter.F{ + Authors: tag.NewFromAny(adm), + Kinds: kind.NewS(kind.New(kind.FollowList.K)), + } + var idxs []database.Range + if idxs, err = database.GetIndexesFromFilter(fl); chk.E(err) { + return + } + var sers types.Uint40s + for _, idx := range idxs { + var s types.Uint40s + if s, err = f.D.GetSerialsByRange(idx); chk.E(err) { + continue + } + sers = append(sers, s...) + } + if len(sers) > 0 { + for _, s := range sers { + var ev *event.E + if ev, err = f.D.FetchEventBySerial(s); chk.E(err) { + continue + } + log.I.F("admin follow list:\n%s", ev.Serialize()) + for _, v := range ev.Tags.GetAll([]byte("p")) { + log.I.F("adding follow: %s", v.Value()) + var a []byte + if a, err = hex.Dec(string(v.Value())); chk.E(err) { + continue + } + f.follows = append(f.follows, a) + } + } + } + } + return +} + +func (f *Follows) GetAccessLevel(pub []byte) (level string) { + if f.cfg == nil { + return "write" + } + f.followsMx.RLock() + defer f.followsMx.RUnlock() + for _, v := range f.admins { + if utils.FastEqual(v, pub) { + return "admin" + } + } + for _, v := range f.follows { + if utils.FastEqual(v, pub) { + return "write" + } + } + return "read" +} + +func (f *Follows) GetACLInfo() (name, description, documentation string) { + return "follows", "whitelist follows of admins", + `This ACL mode searches for follow lists of admins and grants all followers write access` +} + +func (f *Follows) Type() string { return "follows" } + +func init() { + log.T.F("registering follows ACL") + Registry.Register(new(Follows)) +} diff --git a/pkg/acl/go.mod b/pkg/acl/go.mod index 034e770..9de3d23 100644 --- a/pkg/acl/go.mod +++ b/pkg/acl/go.mod @@ -3,12 +3,14 @@ module acl.orly go 1.25.0 replace ( + acl.orly => ../acl crypto.orly => ../crypto - encoders.orly => ../encoders database.orly => ../database + encoders.orly => ../encoders interfaces.orly => ../interfaces next.orly.dev => ../../ protocol.orly => ../protocol utils.orly => ../utils - acl.orly => ../acl ) + +require interfaces.orly v0.0.0-00010101000000-000000000000 diff --git a/pkg/database/save-event.go b/pkg/database/save-event.go index 1e033cd..407caca 100644 --- a/pkg/database/save-event.go +++ b/pkg/database/save-event.go @@ -7,20 +7,99 @@ import ( "database.orly/indexes" "database.orly/indexes/types" "encoders.orly/event" + "encoders.orly/filter" + "encoders.orly/kind" + "encoders.orly/tag" "github.com/dgraph-io/badger/v4" "lol.mleku.dev/chk" "lol.mleku.dev/errorf" "lol.mleku.dev/log" ) +func (d *D) GetSerialsFromFilter(f *filter.F) ( + sers types.Uint40s, err error, +) { + var idxs []Range + if idxs, err = GetIndexesFromFilter(f); chk.E(err) { + return + } + for _, idx := range idxs { + var s types.Uint40s + if s, err = d.GetSerialsByRange(idx); chk.E(err) { + continue + } + sers = append(sers, s...) + } + return +} + // SaveEvent saves an event to the database, generating all the necessary indexes. func (d *D) SaveEvent(c context.Context, ev *event.E) (kc, vc int, err error) { + if ev == nil { + err = errorf.E("nil event") + return + } // check if the event already exists var ser *types.Uint40 if ser, err = d.GetSerialById(ev.ID); err == nil && ser != nil { err = errorf.E("event already exists: %0x", ev.ID) return } + // check for replacement + if kind.IsReplaceable(ev.Kind) { + // find the events and delete them + f := &filter.F{ + Authors: tag.NewFromBytesSlice(ev.Pubkey), + Kinds: kind.NewS(kind.New(ev.Kind)), + } + var sers types.Uint40s + if sers, err = d.GetSerialsFromFilter(f); chk.E(err) { + return + } + // if found, delete them + if len(sers) > 0 { + for _, s := range sers { + var oldEv *event.E + if oldEv, err = d.FetchEventBySerial(s); chk.E(err) { + continue + } + if err = d.DeleteEventBySerial( + c, s, oldEv, + ); chk.E(err) { + continue + } + } + } + } else if kind.IsParameterizedReplaceable(ev.Kind) { + // find the events and delete them + f := &filter.F{ + Authors: tag.NewFromBytesSlice(ev.Pubkey), + Kinds: kind.NewS(kind.New(ev.Kind)), + Tags: tag.NewS( + tag.NewFromAny( + "d", ev.Tags.GetFirst([]byte("d")), + ), + ), + } + var sers types.Uint40s + if sers, err = d.GetSerialsFromFilter(f); chk.E(err) { + return + } + // if found, delete them + if len(sers) > 0 { + for _, s := range sers { + var oldEv *event.E + if oldEv, err = d.FetchEventBySerial(s); chk.E(err) { + continue + } + if err = d.DeleteEventBySerial( + c, s, oldEv, + ); chk.E(err) { + continue + } + } + } + } // Get the next sequence number for the event var serial uint64 if serial, err = d.seq.Next(); chk.E(err) { diff --git a/pkg/interfaces/acl/acl.go b/pkg/interfaces/acl/acl.go index 690e872..cbce759 100644 --- a/pkg/interfaces/acl/acl.go +++ b/pkg/interfaces/acl/acl.go @@ -1,6 +1,10 @@ // Package acl is an interface for implementing arbitrary access control lists. package acl +import ( + "interfaces.orly/typer" +) + const ( // Read means read only Read = "read" @@ -16,10 +20,12 @@ const ( ) type I interface { + Configure(cfg ...any) (err error) // GetAccessLevel returns the access level string for a given pubkey. GetAccessLevel(pub []byte) (level string) // GetACLInfo returns the name and a description of the ACL, which should // explain briefly how it works, and then a long text of documentation of // the ACL's rules and configuration (in asciidoc or markdown). GetACLInfo() (name, description, documentation string) + typer.T }