From 38f9b00829593251ca326485e0cfd784b625c7ee Mon Sep 17 00:00:00 2001 From: Onion Limited Date: Thu, 22 Oct 2020 07:12:35 +0000 Subject: [PATCH] Initial commit --- .gitignore | 1 + Makefile | 10 ++ config.go | 27 +++++ go.mod | 19 ++++ go.sum | 88 +++++++++++++++ logger/config.go | 47 ++++++++ main.go | 100 +++++++++++++++++ members/members.go | 33 ++++++ messagelog/errors.go | 7 ++ messagelog/log.go | 51 +++++++++ messagelog/message.go | 15 +++ routes.go | 10 ++ server.go | 239 +++++++++++++++++++++++++++++++++++++++++ utils/create_topic.sh | 13 +++ utils/list_messages.sh | 10 ++ utils/push_message.sh | 12 +++ 16 files changed, 682 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/config.go create mode 100644 main.go create mode 100644 members/members.go create mode 100644 messagelog/errors.go create mode 100644 messagelog/log.go create mode 100644 messagelog/message.go create mode 100644 routes.go create mode 100644 server.go create mode 100755 utils/create_topic.sh create mode 100755 utils/list_messages.sh create mode 100755 utils/push_message.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d98860 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +mmb diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b65107d --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +TARGET=mmb +LDFLAGS=-w -s + +.PHONY: all +all: + go build -v -ldflags="$(LDFLAGS)" -o "$(TARGET)" + +.PHONY: clean +clean: + $(RM) $(TARGET) diff --git a/config.go b/config.go new file mode 100644 index 0000000..c2285d5 --- /dev/null +++ b/config.go @@ -0,0 +1,27 @@ +package main + +import "strings" + +type authString string + +func (s authString) split() []string { + return strings.SplitN(string(s), ":", 2) +} + +func (s authString) Username() string { + return s.split()[0] +} + +func (s authString) Password() string { + ss := s.split() + if len(ss) < 2 { + return "" + } + return ss[1] +} + +type config struct { + Listen string `long:"listen" description:"Listen on address" default:":8080" env:"HTTP_LISTEN"` + LogLevel string `long:"log-level" description:"Set log level" default:"info" env:"LOG_LEVEL"` + ManagementCredentials authString `long:"management-credentials" description:"Management credentials" env:"MANAGEMENT_CREDENTIALS" required:"true"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c4ba1b --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.wownero.com/onionltd/monero-multisig-broker + +go 1.14 + +require ( + github.com/hashicorp/go-uuid v1.0.1 + github.com/jessevdk/go-flags v1.4.0 + github.com/labstack/echo/v4 v4.1.17 + github.com/pkg/errors v0.9.1 // indirect + github.com/sethvargo/go-password v0.2.0 + github.com/stretchr/testify v1.6.1 // indirect + go.uber.org/zap v1.16.0 + golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect + golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 // indirect + golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/validator.v2 v2.0.0-20200605151824-2b28d334fa05 + gopkg.in/yaml.v2 v2.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..65ff382 --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labstack/echo/v4 v4.1.17 h1:PQIBaRplyRy3OjwILGkPg89JRtH2x5bssi59G2EL3fo= +github.com/labstack/echo/v4 v4.1.17/go.mod h1:Tn2yRQL/UclUalpb5rPdXDevbkJ+lp/2svdyFBg6CHQ= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/pkg/errors v0.8.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= +github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 h1:9UQO31fZ+0aKQOFldThf7BKPMJTiBfWycGh/u3UoO88= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +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= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/validator.v2 v2.0.0-20200605151824-2b28d334fa05 h1:l9eKDCWy9n7C5NAiQAMvDePh0vyLAweR6LcSUVXFUGg= +gopkg.in/validator.v2 v2.0.0-20200605151824-2b28d334fa05/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/logger/config.go b/logger/config.go new file mode 100644 index 0000000..13695f0 --- /dev/null +++ b/logger/config.go @@ -0,0 +1,47 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "strings" +) + +var DefaultConfig = zap.Config{ + Encoding: "console", + Level: zap.NewAtomicLevelAt(zap.DebugLevel), + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + EncoderConfig: zapcore.EncoderConfig{ + MessageKey: "message", + + LevelKey: "level", + EncodeLevel: zapcore.CapitalLevelEncoder, + + TimeKey: "time", + EncodeTime: zapcore.ISO8601TimeEncoder, + + NameKey: "name", + EncodeName: zapcore.FullNameEncoder, + }, +} + +func translateLogLevel(s string) zapcore.Level { + switch strings.ToLower(s) { + case "debug": + return zap.DebugLevel + case "info": + return zap.InfoLevel + case "warning": + return zap.WarnLevel + case "error": + return zap.ErrorLevel + default: + return zap.InfoLevel + } +} + +func DefaultConfigWithLogLevel(l string) zap.Config { + cfg := DefaultConfig + cfg.Level = zap.NewAtomicLevelAt(translateLogLevel(l)) + return cfg +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c6c061f --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "fmt" + "git.wownero.com/onionltd/monero-multisig-broker/logger" + "git.wownero.com/onionltd/monero-multisig-broker/members" + "git.wownero.com/onionltd/monero-multisig-broker/messagelog" + "github.com/jessevdk/go-flags" + "github.com/labstack/echo/v4" + "go.uber.org/zap" + "net/http" + "os" + "os/signal" + "sync" + "time" +) + +func run() error { + cfg, err := setupConfig() + if err != nil { + return err + } + + rootLogger, err := setupLogger(cfg) + if err != nil { + return err + } + httpdLogger := rootLogger.Named("httpd") + + router := setupRouter() + + server := server{ + logger: httpdLogger, + config: cfg, + router: router, + messageLog: messagelog.New(), + memberDB: members.NewDatabase(), + } + server.routes() + + // Handle termination signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + + go func() { + <-sigCh + rootLogger.Warn("received a termination signal") + ctx, _ := context.WithTimeout(context.Background(), 5*time.Second) + _ = router.Shutdown(ctx) + }() + + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + defer wg.Done() + if err := router.Start(cfg.Listen); err != nil { + if err != http.ErrServerClosed { + rootLogger.Error("http server error", zap.Error(err)) + die() + } + } + }() + + wg.Wait() + return nil +} + +func setupConfig() (*config, error) { + cfg := &config{} + parser := flags.NewParser(cfg, flags.HelpFlag) + if _, err := parser.Parse(); err != nil { + return nil, fmt.Errorf("failed to parse configuration: %s", err) + } + return cfg, nil +} + +func setupLogger(cfg *config) (*zap.Logger, error) { + return logger.DefaultConfigWithLogLevel(cfg.LogLevel).Build() +} + +func setupRouter() *echo.Echo { + e := echo.New() + e.HideBanner = true + e.HidePort = true + return e +} + +func die() { + p, _ := os.FindProcess(os.Getpid()) + _ = p.Signal(os.Interrupt) +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} diff --git a/members/members.go b/members/members.go new file mode 100644 index 0000000..490d54c --- /dev/null +++ b/members/members.go @@ -0,0 +1,33 @@ +package members + +import "sync" + +type Member struct { + Nickname string + Token string +} + +type Database struct { + members map[string]Member + membersLock sync.RWMutex +} + +func (d *Database) AddMember(member Member) { + // TODO: validate member! + d.membersLock.Lock() + d.members[member.Token] = member + d.membersLock.Unlock() +} + +func (d *Database) GetMember(token string) (Member, bool) { + d.membersLock.RLock() + defer d.membersLock.RUnlock() + member, ok := d.members[token] + return member, ok +} + +func NewDatabase() *Database { + return &Database{ + members: map[string]Member{}, + } +} diff --git a/messagelog/errors.go b/messagelog/errors.go new file mode 100644 index 0000000..f987fc7 --- /dev/null +++ b/messagelog/errors.go @@ -0,0 +1,7 @@ +package messagelog + +type ErrInvalidMessage struct { +} + +type ErrTopicNotFound struct { +} diff --git a/messagelog/log.go b/messagelog/log.go new file mode 100644 index 0000000..f7447f0 --- /dev/null +++ b/messagelog/log.go @@ -0,0 +1,51 @@ +package messagelog + +import ( + "errors" + "sync" +) + +type Log struct { + log map[string][]Message + logLock sync.RWMutex +} + +func (ml *Log) AddTopic(topic string, capacity int) { + ml.logLock.Lock() + ml.log[topic] = make([]Message, 0, capacity) + ml.logLock.Unlock() +} + +func (ml *Log) Push(topic string, message Message) error { + if err := message.validate(); err != nil { + return err + } + ml.logLock.Lock() + defer ml.logLock.Unlock() + log, ok := ml.log[topic] + if !ok { + return errors.New("topic doesn't exist") + } + message.Index = len(log) + ml.log[topic] = append(log, message) + return nil +} + +func (ml *Log) List(topic string, offset int) ([]Message, error) { + ml.logLock.RLock() + defer ml.logLock.RUnlock() + log, ok := ml.log[topic] + if !ok { + return nil, errors.New("topic doesn't exist") + } + if offset >= len(log) { + return []Message{}, nil + } + return log[offset:], nil +} + +func New() *Log { + return &Log{ + log: map[string][]Message{}, + } +} diff --git a/messagelog/message.go b/messagelog/message.go new file mode 100644 index 0000000..736464c --- /dev/null +++ b/messagelog/message.go @@ -0,0 +1,15 @@ +package messagelog + +import "gopkg.in/validator.v2" + +type Message struct { + Index int `json:"index"` + Sender string `json:"sender" validate:"min=1"` + ContentType string `json:"content_type" validate:"min=1"` + Content interface{} `json:"content" validate:"nonnil"` +} + +func (m Message) validate() error { + // TODO: Use ErrInvalidMessage! + return validator.Validate(m) +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..fafa18f --- /dev/null +++ b/routes.go @@ -0,0 +1,10 @@ +package main + +func (s *server) routes() { + s.router.Use(s.httpLogger()) + s.router.HTTPErrorHandler = s.defaultErrorHandler + + s.router.POST("/api/v1/multisig", s.handleInitMultisig(), s.authManager()) + s.router.GET("/api/v1/multisig/:topic", s.handleListMessages(), s.authMember()) + s.router.PUT("/api/v1/multisig/:topic", s.handlePushMessage(), s.authMember()) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..bfb11eb --- /dev/null +++ b/server.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/base32" + "encoding/json" + "git.wownero.com/onionltd/monero-multisig-broker/members" + "git.wownero.com/onionltd/monero-multisig-broker/messagelog" + "github.com/hashicorp/go-uuid" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/sethvargo/go-password/password" + "go.uber.org/zap" + "net/http" + "strconv" +) + +type server struct { + logger *zap.Logger + router *echo.Echo + config *config + messageLog *messagelog.Log + memberDB *members.Database +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} + +func (s *server) defaultErrorHandler(err error, c echo.Context) { + code := http.StatusInternalServerError + if he, ok := err.(*echo.HTTPError); ok { + code = he.Code + } else { + code = http.StatusInternalServerError + } + if !c.Response().Committed { + if c.Request().Method == http.MethodHead { + _ = c.NoContent(code) + } else { + _ = c.JSON(code, map[string]interface{}{ + "error": http.StatusText(code), + }) + } + } +} + +func (s *server) handleInitMultisig() echo.HandlerFunc { + type member struct{} + + type input struct { + Members map[string]member `json:"members"` + } + + type secret struct { + Host string `json:"host"` + Topic string `json:"topic"` + Token string `json:"token"` + } + encodeSecret := func(s secret) (string, error) { + b, err := json.Marshal(s) + if err != nil { + return "", err + } + return base32.StdEncoding.EncodeToString(b), nil + } + + type output struct { + Topic string `json:"topic"` + Secrets map[string]string `json:"secrets"` + } + + return func(c echo.Context) error { + in := &input{} + + if err := c.Bind(in); err != nil { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": err.Error(), + }) + } + + // TODO: validate input! + + topic, err := uuid.GenerateUUID() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "failed to generate UUID", + }) + } + + memberSecrets := map[string]string{} + for nickname, _ := range in.Members { + // TODO: put password length in config. + token, err := password.Generate(18, 6, 0, false, false) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "failed to generate a token", + }) + } + + member := members.Member{ + Nickname: nickname, + Token: token, + } + s.memberDB.AddMember(member) + + secret := secret{ + // TODO: put URL in the config. + Host: c.Request().Host, + Topic: topic, + Token: token, + } + encoded, err := encodeSecret(secret) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "failed to encode secret data", + }) + } + memberSecrets[nickname] = encoded + } + + // TODO: put initial capacity in the config. + s.messageLog.AddTopic(topic, 128) + + out := output{ + Topic: topic, + Secrets: memberSecrets, + } + + return c.JSON(http.StatusCreated, out) + } +} + +func (s *server) handleListMessages() echo.HandlerFunc { + offsetToNumber := func(s string) (int64, error) { + if s == "" { + return 0, nil + } + return strconv.ParseInt(s, 10, 64) + } + + return func(c echo.Context) error { + topic := c.Param("topic") + offset, err := offsetToNumber(c.QueryParam("offset")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": "offset is not a number", + }) + } + + messages, err := s.messageLog.List(topic, int(offset)) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]interface{}{ + "error": err.Error(), + }) + } + + return c.JSON(http.StatusOK, messages) + } +} + +func (s *server) handlePushMessage() echo.HandlerFunc { + return func(c echo.Context) error { + message := messagelog.Message{} + + if err := c.Bind(&message); err != nil { + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": "invalid message format", + }) + } + + member, ok := c.Get("member").(members.Member) + if !ok { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "internal server error", + }) + } + message.Sender = member.Nickname + + topic := c.Param("topic") + if err := s.messageLog.Push(topic, message); err != nil { + return c.JSON(http.StatusNotFound, map[string]interface{}{ + "error": err.Error(), + }) + } + + return c.String(http.StatusNoContent, "") + } +} + +func (s *server) authMember() echo.MiddlewareFunc { + return middleware.KeyAuth(func(authKey string, c echo.Context) (bool, error) { + member, ok := s.memberDB.GetMember(authKey) + if !ok { + return false, nil + } + c.Set("member", member) + return true, nil + }) +} + +func (s *server) authManager() echo.MiddlewareFunc { + return middleware.BasicAuth(func(authUsername, authPassword string, c echo.Context) (bool, error) { + user := s.config.ManagementCredentials.Username() + pass := s.config.ManagementCredentials.Password() + if user != authUsername || pass != authPassword { + return false, nil + } + return true, nil + }) +} + +func (s *server) httpLogger() echo.MiddlewareFunc { + return func(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := h(c) + statusCode := 0 + if v, ok := err.(*echo.HTTPError); ok { + statusCode = v.Code + } else { + statusCode = c.Response().Status + } + + if writer := s.logger.Check(zap.InfoLevel, ""); writer != nil { + writer.Write( + zap.Reflect("request", map[string]interface{}{ + "method": c.Request().Method, + "path": c.Request().URL.RequestURI(), + "user_agent": c.Request().UserAgent(), + }), + zap.Reflect("response", map[string]interface{}{ + "code": statusCode, + "status": http.StatusText(statusCode), + }), + ) + } + return err + } + } +} diff --git a/utils/create_topic.sh b/utils/create_topic.sh new file mode 100755 index 0000000..a14bb14 --- /dev/null +++ b/utils/create_topic.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +if [ $# -lt 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +response="$(curl -u admin:pass -s -X POST -H "Content-Type: application/json" \ + --data '{"members": {"'$1'":{}, "'$2'":{}}}' \ + http://localhost:8080/api/v1/multisig)" + +echo $response | jq -r .secrets.$1 | base32 -d | jq '. | {"'$1'": .}' +echo $response | jq -r .secrets.$2 | base32 -d | jq '. | {"'$2'": .}' diff --git a/utils/list_messages.sh b/utils/list_messages.sh new file mode 100755 index 0000000..46ce848 --- /dev/null +++ b/utils/list_messages.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +if [ $# -lt 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +curl -H "Authorization: Bearer $2" -s -H "Content-Type: application/json" \ + http://localhost:8080/api/v1/multisig/$1 \ +| jq . diff --git a/utils/push_message.sh b/utils/push_message.sh new file mode 100755 index 0000000..ea67e6c --- /dev/null +++ b/utils/push_message.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +if [ $# -lt 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +message='{"content_type": "application/json","content":{"body":"hello world!"}}' + +curl -H "Authorization: Bearer $2" -s -X PUT -H "Content-Type: application/json" \ + --data "$message" \ + http://localhost:8080/api/v1/multisig/$1