From 6642cba977cbb6562d4579ce288f19b99b8111b7 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 23 Sep 2025 16:44:26 -0700 Subject: [PATCH] Add handler --- .ackrc | 1 + .fdignore | 1 + .gitignore | 2 ++ CLAUDE.md | 12 ++++++++ README.md | 6 ++-- cmd/relay/main.go | 44 +++++++++++++++++++++++++++ go.mod | 36 ++++++++++++++++++++++ go.sum | 76 +++++++++++++++++++++++++++++++++++++++++++++++ justfile | 8 +++++ zooid/config.go | 45 ++++++++++++++++++++++++++++ zooid/http.go | 17 +++++++++++ zooid/relay.go | 38 ++++++++++++++++++++++++ zooid/util.go | 31 +++++++++++++++++++ 13 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 .ackrc create mode 100644 .fdignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 cmd/relay/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 justfile create mode 100644 zooid/config.go create mode 100644 zooid/http.go create mode 100644 zooid/relay.go create mode 100644 zooid/util.go diff --git a/.ackrc b/.ackrc new file mode 100644 index 0000000..8052b27 --- /dev/null +++ b/.ackrc @@ -0,0 +1 @@ +--ignore-dir=bin diff --git a/.fdignore b/.fdignore new file mode 100644 index 0000000..ba077a4 --- /dev/null +++ b/.fdignore @@ -0,0 +1 @@ +bin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..409f624 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +config diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d1200d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +`README.md` contains high-level project information. +`justfile` contains common commands. + +## Codebase Overview + +- **zooid/config.go**: Defines `Config` struct with TOML tags for relay configuration (self info, groups, roles, data paths). Contains `loadConfig()` function that parses hostname-based config files from `configs/` directory. + +- **zooid/http.go**: Core HTTP handling with dynamic instance creation. `getInstance()` function loads config and creates khatru relay instances on-demand. `ServeHTTP()` function routes requests to appropriate relay instances based on hostname. + +- **zooid/util.go**: Environment variable utilities. `Env()` function with fallback support for configuration. + +- **cmd/relay/main.go**: Main entry point that starts HTTP server with graceful shutdown handling. Uses `zooid.ServeHTTP` as the handler wrapped in `http.HandlerFunc`. diff --git a/README.md b/README.md index 2e63c2a..006de90 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Costasiella +# Zooid This is a multi-tenant relay based on [Khatru](https://gitworkshop.dev/fiatjaf.com/nostrlib/tree/master/khatru) which implements a range of access controls. It's designed to be used with [Flotilla](https://flotilla.social) as a community relay (complete with NIP 29 support), but it can also be used outside of a community context. ## Architecture -A single Costasiella instance can run any number of "virtual" relays. The `config` directory can contain any number of configuration files, each of which represents a single virtual relay. +A single zooid instance can run any number of "virtual" relays. The `config` directory can contain any number of configuration files, each of which represents a single virtual relay. ## Configuration @@ -79,4 +79,4 @@ auto_join = false ## Development -Run `go run .` to run the project and `go build .` to build it. Be sure to run `gofmt -s -w .` before committing. +See `justfile` for defined commands. diff --git a/cmd/relay/main.go b/cmd/relay/main.go new file mode 100644 index 0000000..efa39bd --- /dev/null +++ b/cmd/relay/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + "zooid/zooid" +) + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) + + port := zooid.Env("PORT", "3334") + srv := &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: http.HandlerFunc(zooid.ServeHTTP), + } + + go func() { + fmt.Printf("running on :%s\n", port) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("HTTP server error: %v\n", err) + } + }() + + <-shutdown + + fmt.Println("\nShutting down gracefully...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown error: %v\n", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6685928 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module zooid + +go 1.24.1 + +require ( + fiatjaf.com/nostr v0.0.0-20250923223459-3c540e726e17 + github.com/BurntSushi/toml v1.5.0 +) + +require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/coder/websocket v1.8.13 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fasthttp/websocket v1.5.12 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.59.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/net v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d67b54 --- /dev/null +++ b/go.sum @@ -0,0 +1,76 @@ +fiatjaf.com/nostr v0.0.0-20250923223459-3c540e726e17 h1:bCx2ExbAMz3TXOiPn5TS2HWK/90KOq3GOTlHFSguobs= +fiatjaf.com/nostr v0.0.0-20250923223459-3c540e726e17/go.mod h1:Nq86Jjsd0OmsOEImUg0iCcLuqM5B67Nj2eu/2dP74Ss= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/PowerDNS/lmdb-go v1.9.3 h1:AUMY2pZT8WRpkEv39I9Id3MuoHd+NZbTVpNhruVkPTg= +github.com/PowerDNS/lmdb-go v1.9.3/go.mod h1:TE0l+EZK8Z1B4dx070ZxkWTlp8RG1mjN0/+FkFRQMtU= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= +github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= +github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/justfile b/justfile new file mode 100644 index 0000000..a71ac63 --- /dev/null +++ b/justfile @@ -0,0 +1,8 @@ +run: + go run cmd/relay/main.go + +build: + go build -o bin/zooid cmd/relay/main.go + +fmt: + gofmt -w -s . diff --git a/zooid/config.go b/zooid/config.go new file mode 100644 index 0000000..f2584a6 --- /dev/null +++ b/zooid/config.go @@ -0,0 +1,45 @@ +package zooid + +import ( + "fmt" + "github.com/BurntSushi/toml" + "path/filepath" +) + +type Config struct { + Self struct { + Name string `toml:"name"` + Icon string `toml:"icon"` + Secret string `toml:"secret"` + Pubkey string `toml:"pubkey"` + Description string `toml:"description"` + } `toml:"self"` + + Groups struct { + Enabled bool `toml:"enabled"` + AutoJoin bool `toml:"auto_join"` + AutoLeave bool `toml:"auto_leave"` + } `toml:"groups"` + + Roles map[string]struct { + Pubkeys []string `toml:"pubkeys"` + Nip86Methods []string `toml:"nip86_methods"` + CanInvite bool `toml:"can_invite"` + } `toml:"roles"` + + Data struct { + Sqlite string `toml:"sqlite"` + Media string `toml:"media"` + } `toml:"data"` +} + +func LoadConfig(hostname string) (*Config, error) { + path := filepath.Join("configs", hostname) + + var config Config + if _, err := toml.DecodeFile(path, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) + } + + return &config, nil +} diff --git a/zooid/http.go b/zooid/http.go new file mode 100644 index 0000000..8d36819 --- /dev/null +++ b/zooid/http.go @@ -0,0 +1,17 @@ +package zooid + +import ( + "log" + "net/http" +) + +func ServeHTTP(w http.ResponseWriter, r *http.Request) { + relay, err := GetRelay(r.Host) + if err != nil { + log.Printf("Failed to load relay config for hostname %s: %v", r.Host, err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + relay.ServeHTTP(w, r) +} diff --git a/zooid/relay.go b/zooid/relay.go new file mode 100644 index 0000000..66e4578 --- /dev/null +++ b/zooid/relay.go @@ -0,0 +1,38 @@ +package zooid + +import ( + "fiatjaf.com/nostr/khatru" + "sync" +) + +func MakeRelay(hostname string, config *Config) *khatru.Relay { + relay := khatru.NewRelay() + + return relay +} + +var ( + relays map[string]*khatru.Relay + relayOnce sync.Once +) + +func GetRelay(hostname string) (*khatru.Relay, error) { + relayOnce.Do(func() { + relays = make(map[string]*khatru.Relay) + }) + + relay, exists := relays[hostname] + if !exists { + config, err := LoadConfig(hostname) + if err != nil { + return nil, err + } + + newRelay := MakeRelay(hostname, config) + + relays[hostname] = newRelay + relay = newRelay + } + + return relay, nil +} diff --git a/zooid/util.go b/zooid/util.go new file mode 100644 index 0000000..9cfe94c --- /dev/null +++ b/zooid/util.go @@ -0,0 +1,31 @@ +package zooid + +import ( + "os" + "strings" + "sync" +) + +var ( + env map[string]string + envOnce sync.Once +) + +func Env(k string, fallback ...string) (v string) { + envOnce.Do(func() { + env = make(map[string]string) + + for _, item := range os.Environ() { + parts := strings.SplitN(item, "=", 2) + env[parts[0]] = parts[1] + } + }) + + v = env[k] + + if v == "" && len(fallback) > 0 { + v = fallback[0] + } + + return v +}