commit 56de805f549603d66af06bb999f3d706d66f5a86 Author: Miroslav Šedivý Date: Thu Oct 22 16:54:50 2020 +0200 initial commit - from neko open source repository.. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..3dce4145 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..b5884926 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +DISPLAY=:99.0 +PION_LOG_TRACE=all diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..02c7b7db --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "launch", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/neko", + "envFile": "${workspaceFolder}/.env.development", + "output": "${workspaceFolder}/bin/debug/neko", + "cwd": "${workspaceFolder}/", + "args": ["serve", "-d", "--bind", ":3000", "--static", "../client/dist", "--password", "neko", "--password_admin", "admin"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..452ccec1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ + +{ + "go.formatTool": "goformat", + "go.inferGopath": false, + "go.autocompleteUnimportedPackages": true, + "go.delveConfig": { + "useApiV1": false, + "dlvLoadConfig": { + "followPointers": true, + "maxVariableRecurse": 3, + "maxStringLen": 400, + "maxArrayValues": 400, + "maxStructFields": -1 + } + }, + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + } + } diff --git a/README.md b/README.md new file mode 100644 index 00000000..cb4b5bc1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# n.eko core + +Virtual environment backend. diff --git a/build b/build new file mode 100644 index 00000000..895b12a7 --- /dev/null +++ b/build @@ -0,0 +1,10 @@ +#!/bin/bash + +set -ex + +BUILD_TIME=`date -u +'%Y-%m-%dT%H:%M:%SZ'` +GIT_COMMIT=`git rev-parse --short HEAD` +GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD` +GIT_DIRTY=`git diff-index --quiet HEAD -- || echo "✗-"` + +go build -o bin/neko -ldflags "-s -X 'n.eko.moe/neko.buildDate=${BUILD_TIME}' -X 'n.eko.moe/neko.gitCommit=${GIT_DIRTY}${GIT_COMMIT}' -X 'n.eko.moe/neko.gitBranch=${GIT_BRANCH}'" -i cmd/neko/main.go diff --git a/cmd/neko/main.go b/cmd/neko/main.go new file mode 100644 index 00000000..d4de0bfc --- /dev/null +++ b/cmd/neko/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + + "github.com/rs/zerolog/log" + + "n.eko.moe/neko" + "n.eko.moe/neko/cmd" + "n.eko.moe/neko/internal/utils" +) + +func main() { + fmt.Print(utils.Colorf(neko.Header, "server", neko.Service.Version)) + if err := cmd.Execute(); err != nil { + log.Panic().Err(err).Msg("failed to execute command") + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..293f3450 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/diode" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "n.eko.moe/neko" +) + +func Execute() error { + return root.Execute() +} + +var root = &cobra.Command{ + Use: "neko", + Short: "neko streaming server", + Long: `neko streaming server`, + Version: neko.Service.Version.String(), +} + +func init() { + cobra.OnInitialize(func() { + ////// + // logs + ////// + zerolog.TimeFieldFormat = "" + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + if viper.GetBool("debug") { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + + console := zerolog.ConsoleWriter{Out: os.Stdout} + + if !viper.GetBool("logs") { + log.Logger = log.Output(console) + } else { + + logs := filepath.Join(".", "logs") + if runtime.GOOS == "linux" { + logs = "/var/log/neko" + } + + if _, err := os.Stat(logs); os.IsNotExist(err) { + os.Mkdir(logs, os.ModePerm) + } + + latest := filepath.Join(logs, "neko-latest.log") + _, err := os.Stat(latest) + if err == nil { + err = os.Rename(latest, filepath.Join(logs, "neko."+time.Now().Format("2006-01-02T15-04-05Z07-00")+".log")) + if err != nil { + log.Panic().Err(err).Msg("failed to rotate log file") + } + } + + logf, err := os.OpenFile(latest, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + log.Panic().Err(err).Msg("failed to create log file") + } + + logger := diode.NewWriter(logf, 1000, 10*time.Millisecond, func(missed int) { + fmt.Printf("logger dropped %d messages", missed) + }) + + log.Logger = log.Output(io.MultiWriter(console, logger)) + } + + ////// + // configs + ////// + config := viper.GetString("config") + if config != "" { + viper.SetConfigFile(config) // Use config file from the flag. + } else { + if runtime.GOOS == "linux" { + viper.AddConfigPath("/etc/neko/") + } + + viper.AddConfigPath(".") + viper.SetConfigName("neko") + } + + viper.SetEnvPrefix("NEKO") + viper.AutomaticEnv() // read in environment variables that match + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + log.Error().Err(err) + } + if config != "" { + log.Error().Err(err) + } + } + + file := viper.ConfigFileUsed() + logger := log.With(). + Bool("debug", viper.GetBool("debug")). + Str("logging", viper.GetString("logs")). + Str("config", file). + Logger() + + if file == "" { + logger.Warn().Msg("preflight complete without config file") + } else { + logger.Info().Msg("preflight complete") + } + + neko.Service.Root.Set() + }) + + if err := neko.Service.Root.Init(root); err != nil { + log.Panic().Err(err).Msg("unable to run root command") + } + + root.SetVersionTemplate(neko.Service.Version.Details()) +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 00000000..b6a78077 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "n.eko.moe/neko" + "n.eko.moe/neko/internal/types/config" +) + +func init() { + command := &cobra.Command{ + Use: "serve", + Short: "serve neko streaming server", + Long: `serve neko streaming server`, + Run: neko.Service.ServeCommand, + } + + configs := []config.Config{ + neko.Service.Server, + neko.Service.WebRTC, + neko.Service.Remote, + neko.Service.Broadcast, + neko.Service.WebSocket, + } + + cobra.OnInitialize(func() { + for _, cfg := range configs { + cfg.Set() + } + neko.Service.Preflight() + }) + + for _, cfg := range configs { + if err := cfg.Init(command); err != nil { + log.Panic().Err(err).Msg("unable to run serve command") + } + } + + root.AddCommand(command) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..dfb14cca --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module n.eko.moe/neko + +go 1.13 + +require ( + github.com/coreos/go-etcd v2.0.0+incompatible // indirect + github.com/cpuguy83/go-md2man v1.0.10 // indirect + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-chi/chi v4.1.0+incompatible + github.com/golang/protobuf v1.3.5 // indirect + github.com/gorilla/websocket v1.4.2 + github.com/kataras/go-events v0.0.2 + github.com/lucas-clemente/quic-go v0.15.2 // indirect + github.com/mitchellh/mapstructure v1.2.2 // indirect + github.com/pelletier/go-toml v1.7.0 // indirect + github.com/pion/ice v0.7.12 // indirect + github.com/pion/logging v0.2.2 + github.com/pion/rtp v1.4.0 // indirect + github.com/pion/sdp/v2 v2.3.5 // indirect + github.com/pion/turn v1.4.0 // indirect + github.com/pion/webrtc/v2 v2.2.4 + github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.18.0 + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/cobra v0.0.7 + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.6.2 + github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 // indirect + golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 // indirect + golang.org/x/sys v0.0.0-20200331124033-c3d80250170d // indirect + golang.org/x/text v0.3.2 // indirect + gopkg.in/ini.v1 v1.55.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..67ae498c --- /dev/null +++ b/go.sum @@ -0,0 +1,470 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alangpierce/go-forceexport v0.0.0-20160317203124-8f1d6941cd75/go.mod h1:uAXEEpARkRhCZfEvy/y0Jcc888f9tHCc1W7/UeEtreE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY= +github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w= +github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kataras/go-events v0.0.2 h1:fhyUPXvUbrjIPmH4vRdrAAGoNzdcwJPQmjhg47m1nMU= +github.com/kataras/go-events v0.0.2/go.mod h1:6IxMW59VJdEIqj3bjFGJvGLRdb0WHtrlxPZy9qXctcg= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +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/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9 h1:tbuodUh2vuhOVZAdW3NEUvosFHUMJwUNl7jk/VSEiwc= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= +github.com/lucas-clemente/quic-go v0.15.2 h1:RgxRJ7rPde0Q/uXDeb3/UdblVvxrYGDAG9G9GO78LmI= +github.com/lucas-clemente/quic-go v0.15.2/go.mod h1:qxmO5Y4ZMhdNkunGfxuZnZXnJwYpW9vjQkyrZ7BsgUI= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/marten-seemann/qpack v0.1.0/go.mod h1:LFt1NU/Ptjip0C2CPkhimBz5CGE3WGDAUWqna+CNTrI= +github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNAI4vA= +github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/marten-seemann/qtls v0.8.0 h1:aj+MPLibzKByw8CmG0WvWgbtBkctYPAXeB11cQJC8mo= +github.com/marten-seemann/qtls v0.8.0/go.mod h1:Lao6jDqlCfxyLKYFmZXGm2LSHBgVn+P+ROOex6YkT+k= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= +github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pion/datachannel v1.4.13 h1:ezTn3AtUtXvKemRRjRdUgao/T8bH4ZJwrpOqU8Iz3Ss= +github.com/pion/datachannel v1.4.13/go.mod h1:+rBUwEDonA63KXx994DP/ofyyGVAm6AIMvOqQZxjWRU= +github.com/pion/datachannel v1.4.16 h1:dvuDC0IBMUDQvwO+gRu0Dv+W5j7rrgNpCmtheb6iYnc= +github.com/pion/datachannel v1.4.16/go.mod h1:gRGhxZv7X2/30Qxes4WEXtimKBXcwj/3WsDtBlHnvJY= +github.com/pion/dtls/v2 v2.0.0-rc.3 h1:u9utI+EDJOjOWfrkGQsD8WNssPcTwfYIanFB6oI8K+4= +github.com/pion/dtls/v2 v2.0.0-rc.3/go.mod h1:x0XH+cN5z+l/+/4nYL8r4sB8g6+0d1Zp2Pfkcoz8BKY= +github.com/pion/dtls/v2 v2.0.0-rc.7/go.mod h1:U199DvHpRBN0muE9+tVN4TMy1jvEhZIZ63lk4xkvVSk= +github.com/pion/dtls/v2 v2.0.0-rc.9 h1:wPb0JKmYoleAM2o8vQSPaUM+geJq7l0AdeUlPsg19ec= +github.com/pion/dtls/v2 v2.0.0-rc.9/go.mod h1:6eFkFvpo0T+odQ+39HFEtOO7LX5cUlFqXdSo4ucZtGg= +github.com/pion/ice v0.7.6 h1:EARj1MBq5NYaMtXVhYkK03i0RS/meejNHvZS++K5tSY= +github.com/pion/ice v0.7.6/go.mod h1:4xCajahEEvc5w0AM+Ujx/Rr2EczON/fKndi3jLyDdh4= +github.com/pion/ice v0.7.10/go.mod h1:YufCtyMmPeZXGTuARCracYfe0mb3EXbcRUS+vHJB5Cs= +github.com/pion/ice v0.7.12 h1:Lsh4f0Uvh/vOCXSyj+w5C736LrKt66qAKeA2LFwSkn0= +github.com/pion/ice v0.7.12/go.mod h1:yLt/9LAJEZXFtnOBdpq5YGaOF9SsDjVGCvzF3MF4k5k= +github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.4 h1:O4vvVqr4DGX63vzmO6Fw9vpy3lfztVWHGCQfyw0ZLSY= +github.com/pion/mdns v0.0.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0= +github.com/pion/quic v0.1.1 h1:D951FV+TOqI9A0rTF7tHx0Loooqz+nyzjEyj8o3PuMA= +github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k= +github.com/pion/rtcp v1.2.1 h1:S3yG4KpYAiSmBVqKAfgRa5JdwBNj4zK3RLUa8JYdhak= +github.com/pion/rtcp v1.2.1/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM= +github.com/pion/rtp v1.1.3/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/rtp v1.1.4 h1:P6xh8Y8JfzR7+JAbI79X2M8kfYETaqbuM5Otm+Z+k6U= +github.com/pion/rtp v1.1.4/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/rtp v1.3.2/go.mod h1:q9wPnA96pu2urCcW/sK/RiDn597bhGoAQQ+y2fDwHuY= +github.com/pion/rtp v1.4.0 h1:EkeHEXKuJhZoRUxtL2Ie80vVg9gBH+poT9UoL8M14nw= +github.com/pion/rtp v1.4.0/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/sctp v1.7.3 h1:Pok18oncuAq/WjNxbyltfBSLvbv/6QSCyVJKYyDWP5M= +github.com/pion/sctp v1.7.3/go.mod h1:c6C9jaDGX7f5xeSRVju/140XatpO9sOVe81EwpfzAc8= +github.com/pion/sctp v1.7.6 h1:8qZTdJtbKfAns/Hv5L0PAj8FyXcsKhMH1pKUCGisQg4= +github.com/pion/sctp v1.7.6/go.mod h1:ichkYQ5tlgCQwEwvgfdcAolqx1nHbYCxo4D7zK/K0X8= +github.com/pion/sdp/v2 v2.3.1 h1:45dub4NRdwyDmQCD3GIY7DZuqC49GBUwBdjuetvdOr0= +github.com/pion/sdp/v2 v2.3.1/go.mod h1:jccXVYW0fuK6ds2pwKr89SVBDYlCjhgMI6nucl5R5rA= +github.com/pion/sdp/v2 v2.3.4/go.mod h1:jccXVYW0fuK6ds2pwKr89SVBDYlCjhgMI6nucl5R5rA= +github.com/pion/sdp/v2 v2.3.5 h1:DtS9Z9R+E3/mn2jt+RQKBnneK1g+p3PT25+TkQHodfU= +github.com/pion/sdp/v2 v2.3.5/go.mod h1:+ZZf35r1+zbaWYiZLfPutWfx58DAWcGb2QsS3D/s9M8= +github.com/pion/srtp v1.2.6 h1:mHQuAMh0P67R7/j1F260u3O+fbRWLyjKLRPZYYvODFM= +github.com/pion/srtp v1.2.6/go.mod h1:rd8imc5htjfs99XiEoOjLMEOcVjME63UHx9Ek9IGst0= +github.com/pion/srtp v1.3.1 h1:WNDLN41ST0P6cXRpzx97JJW//vChAEo1+Etdqo+UMnM= +github.com/pion/srtp v1.3.1/go.mod h1:nxEytDDGTN+eNKJ1l5gzOCWQFuksgijorsSlgEjc40Y= +github.com/pion/stun v0.3.3 h1:brYuPl9bN9w/VM7OdNzRSLoqsnwlyNvD9MVeJrHjDQw= +github.com/pion/stun v0.3.3/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= +github.com/pion/transport v0.8.9/go.mod h1:lpeSM6KJFejVtZf8k0fgeN7zE73APQpTF83WvA1FVP8= +github.com/pion/transport v0.8.10 h1:lTiobMEw2PG6BH/mgIVqTV2mBp/mPT+IJLaN8ZxgdHk= +github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8= +github.com/pion/transport v0.10.0 h1:9M12BSneJm6ggGhJyWpDveFOstJsTiQjkLf4M44rm80= +github.com/pion/transport v0.10.0/go.mod h1:BnHnUipd0rZQyTVB2SBGojFHT9CBt5C5TcsJSQGkvSE= +github.com/pion/turn v1.4.0 h1:7NUMRehQz4fIo53Qv9ui1kJ0Kr1CA82I81RHKHCeM80= +github.com/pion/turn v1.4.0/go.mod h1:aDSi6hWX/hd1+gKia9cExZOR0MU95O7zX9p3Gw/P2aU= +github.com/pion/turn/v2 v2.0.3 h1:SJUUIbcPoehlyZgMyIUbBBDhI03sBx32x3JuSIBKBWA= +github.com/pion/turn/v2 v2.0.3/go.mod h1:kl1hmT3NxcLynpXVnwJgObL8C9NaCyPTeqI2DcCpSZs= +github.com/pion/webrtc/v2 v2.1.18 h1:g0VN0xfEUSlVNfQmlCD6yOeXy/tMaktESBmHMnBS3bk= +github.com/pion/webrtc/v2 v2.1.18/go.mod h1:m0rKlYgLRZWyhmcMWegpF6xtK1ASxmOg8DAR74ttzQY= +github.com/pion/webrtc/v2 v2.2.4 h1:elWyBI/6S2kNoJ5rcTj2EMAvp/fmeiEqiuYbUd0ShRA= +github.com/pion/webrtc/v2 v2.2.4/go.mod h1:9vTqkEISnB5AXlggrFSBLGSz/kopQuYUYzHubuFpnCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3 h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084 h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= +github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= +github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU= +github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk= +github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/spf13/viper v1.6.2 h1:7aKfF+e8/k68gda3LOjo5RxiUqddoFxVq4BKBPrxk5E= +github.com/spf13/viper v1.6.2/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191029031824-8986dd9e96cf/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 h1:fpnn/HnJONpIu6hkXi1u/7rR0NzilgWr4T0JmWkEitk= +golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190228165749-92fc7df08ae7/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab h1:FvshnhkKW+LO3HWHodML8kuVX8rnJTxKm9dFPuI68UM= +golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab/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-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= +gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/internal/broadcast/manager.go b/internal/broadcast/manager.go new file mode 100644 index 00000000..d6ebbe0b --- /dev/null +++ b/internal/broadcast/manager.go @@ -0,0 +1,83 @@ +package broadcast + +import ( + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/gst" + "n.eko.moe/neko/internal/types/config" +) + +type BroadcastManager struct { + logger zerolog.Logger + pipeline *gst.Pipeline + remote *config.Remote + config *config.Broadcast + enabled bool + url string +} + +func New(remote *config.Remote, config *config.Broadcast) *BroadcastManager { + return &BroadcastManager{ + logger: log.With().Str("module", "remote").Logger(), + remote: remote, + config: config, + enabled: false, + url: "", + } +} + +func (manager *BroadcastManager) Start() { + if !manager.enabled || manager.IsActive() { + return + } + + var err error + manager.pipeline, err = gst.CreateRTMPPipeline( + manager.remote.Device, + manager.remote.Display, + manager.config.Pipeline, + manager.url, + ) + + manager.logger.Info(). + Str("audio_device", manager.remote.Device). + Str("video_display", manager.remote.Display). + Str("rtmp_pipeline_src", manager.pipeline.Src). + Msgf("RTMP pipeline is starting...") + + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create rtmp pipeline") + return + } + + manager.pipeline.Play() +} + +func (manager *BroadcastManager) Stop() { + if !manager.IsActive() { + return + } + + manager.pipeline.Stop() + manager.pipeline = nil +} + +func (manager *BroadcastManager) IsActive() bool { + return manager.pipeline != nil +} + +func (manager *BroadcastManager) Create(url string) { + manager.url = url + manager.enabled = true + manager.Start() +} + +func (manager *BroadcastManager) Destroy() { + manager.Stop() + manager.enabled = false +} + +func (manager *BroadcastManager) GetUrl() string { + return manager.url +} diff --git a/internal/gst/gst.c b/internal/gst/gst.c new file mode 100644 index 00000000..924787fb --- /dev/null +++ b/internal/gst/gst.c @@ -0,0 +1,95 @@ +#include "gst.h" + +#include + +typedef struct SampleHandlerUserData { + int pipelineId; +} SampleHandlerUserData; + +void gstreamer_init(void) { + gst_init(NULL, NULL); +} + +GMainLoop *gstreamer_send_main_loop = NULL; +void gstreamer_send_start_mainloop(void) { + gstreamer_send_main_loop = g_main_loop_new(NULL, FALSE); + + g_main_loop_run(gstreamer_send_main_loop); +} + +static gboolean gstreamer_send_bus_call(GstBus *bus, GstMessage *msg, gpointer data) { + switch (GST_MESSAGE_TYPE(msg)) { + + case GST_MESSAGE_EOS: + g_print("End of stream\n"); + exit(1); + break; + + case GST_MESSAGE_ERROR: { + gchar *debug; + GError *error; + + gst_message_parse_error(msg, &error, &debug); + g_free(debug); + + g_printerr("Error: %s\n", error->message); + g_error_free(error); + exit(1); + } + default: + break; + } + + return TRUE; +} + +GstFlowReturn gstreamer_send_new_sample_handler(GstElement *object, gpointer user_data) { + GstSample *sample = NULL; + GstBuffer *buffer = NULL; + gpointer copy = NULL; + gsize copy_size = 0; + SampleHandlerUserData *s = (SampleHandlerUserData *)user_data; + + g_signal_emit_by_name (object, "pull-sample", &sample); + if (sample) { + buffer = gst_sample_get_buffer(sample); + if (buffer) { + gst_buffer_extract_dup(buffer, 0, gst_buffer_get_size(buffer), ©, ©_size); + goHandlePipelineBuffer(copy, copy_size, GST_BUFFER_DURATION(buffer), s->pipelineId); + } + gst_sample_unref (sample); + } + + return GST_FLOW_OK; +} + +GstElement *gstreamer_send_create_pipeline(char *pipeline) { + GError *error = NULL; + return gst_parse_launch(pipeline, &error); +} + +void gstreamer_send_start_pipeline(GstElement *pipeline, int pipelineId) { + SampleHandlerUserData *s = calloc(1, sizeof(SampleHandlerUserData)); + s->pipelineId = pipelineId; + + GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); + gst_bus_add_watch(bus, gstreamer_send_bus_call, NULL); + gst_object_unref(bus); + + GstElement *appsink = gst_bin_get_by_name(GST_BIN(pipeline), "appsink"); + g_object_set(appsink, "emit-signals", TRUE, NULL); + g_signal_connect(appsink, "new-sample", G_CALLBACK(gstreamer_send_new_sample_handler), s); + gst_object_unref(appsink); + + gst_element_set_state(pipeline, GST_STATE_PLAYING); +} + +void gstreamer_send_play_pipeline(GstElement *pipeline) { + gst_element_set_state(pipeline, GST_STATE_PLAYING); +} + +void gstreamer_send_stop_pipeline(GstElement *pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); +} + + diff --git a/internal/gst/gst.go b/internal/gst/gst.go new file mode 100644 index 00000000..c3fcc56c --- /dev/null +++ b/internal/gst/gst.go @@ -0,0 +1,277 @@ +package gst + +/* +#cgo pkg-config: gstreamer-1.0 gstreamer-app-1.0 + +#include "gst.h" + +*/ +import "C" +import ( + "fmt" + "sync" + "unsafe" + + "github.com/pion/webrtc/v2" + + "n.eko.moe/neko/internal/types" +) + +/* + apt-get install \ + libgstreamer1.0-0 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly\ + gstreamer1.0-libav \ + gstreamer1.0-doc \ + gstreamer1.0-tools \ + gstreamer1.0-x \ + gstreamer1.0-alsa \ + gstreamer1.0-pulseaudio + + gst-inspect-1.0 --version + gst-inspect-1.0 plugin + gst-launch-1.0 ximagesrc show-pointer=true use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert ! queue ! vp8enc error-resilient=partitions keyframe-max-dist=10 auto-alt-ref=true cpu-used=5 deadline=1 ! autovideosink + gst-launch-1.0 pulsesrc ! audioconvert ! opusenc ! autoaudiosink +*/ + +// Pipeline is a wrapper for a GStreamer Pipeline +type Pipeline struct { + Pipeline *C.GstElement + Sample chan types.Sample + CodecName string + ClockRate float32 + Src string + id int +} + +var pipelines = make(map[int]*Pipeline) +var pipelinesLock sync.Mutex +var registry *C.GstRegistry + +const ( + videoClockRate = 90000 + audioClockRate = 48000 + pcmClockRate = 8000 + videoSrc = "ximagesrc xid=%s show-pointer=true use-damage=false ! video/x-raw ! videoconvert ! queue ! " + audioSrc = "pulsesrc device=%s ! audio/x-raw,channels=2 ! audioconvert ! " +) + +func init() { + C.gstreamer_init() + registry = C.gst_registry_get() +} + +// CreateRTMPPipeline creates a GStreamer Pipeline +func CreateRTMPPipeline(pipelineDevice string, pipelineDisplay string, pipelineSrc string, pipelineRTMP string) (*Pipeline, error) { + video := fmt.Sprintf(videoSrc, pipelineDisplay) + audio := fmt.Sprintf(audioSrc, pipelineDevice) + + var pipelineStr string + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc, pipelineRTMP, pipelineDevice, pipelineDisplay) + } else { + pipelineStr = fmt.Sprintf("flvmux name=mux ! rtmpsink location='%s live=1' %s audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. %s x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! mux.", pipelineRTMP, audio, video) + } + + return CreatePipeline(pipelineStr, "", 0) +} + +// CreateAppPipeline creates a GStreamer Pipeline +func CreateAppPipeline(codecName string, pipelineDevice string, pipelineSrc string) (*Pipeline, error) { + pipelineStr := " ! appsink name=appsink" + + var clockRate float32 + + switch codecName { + case webrtc.VP8: + // https://gstreamer.freedesktop.org/documentation/vpx/vp8enc.html?gi-language=c + // gstreamer1.0-plugins-good + // vp8enc error-resilient=partitions keyframe-max-dist=10 auto-alt-ref=true cpu-used=5 deadline=1 + if err := CheckPlugins([]string{"ximagesrc", "vpx"}); err != nil { + return nil, err + } + + clockRate = videoClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(videoSrc+"vp8enc cpu-used=8 threads=2 deadline=1 error-resilient=partitions keyframe-max-dist=10 auto-alt-ref=true"+pipelineStr, pipelineDevice) + } + case webrtc.VP9: + // https://gstreamer.freedesktop.org/documentation/vpx/vp9enc.html?gi-language=c + // gstreamer1.0-plugins-good + // vp9enc + if err := CheckPlugins([]string{"ximagesrc", "vpx"}); err != nil { + return nil, err + } + + clockRate = videoClockRate + + // Causes panic! not sure why... + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(videoSrc+"vp9enc"+pipelineStr, pipelineDevice) + } + case webrtc.H264: + // https://gstreamer.freedesktop.org/documentation/openh264/openh264enc.html?gi-language=c#openh264enc + // gstreamer1.0-plugins-bad + // openh264enc multi-thread=4 complexity=high bitrate=3072000 max-bitrate=4096000 + if err := CheckPlugins([]string{"ximagesrc"}); err != nil { + return nil, err + } + + clockRate = videoClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(videoSrc+"openh264enc multi-thread=4 complexity=high bitrate=3072000 max-bitrate=4096000 ! video/x-h264,stream-format=byte-stream"+pipelineStr, pipelineDevice) + + // https://gstreamer.freedesktop.org/documentation/x264/index.html?gi-language=c + // gstreamer1.0-plugins-ugly + // video/x-raw,format=I420 ! x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream + if err := CheckPlugins([]string{"openh264"}); err != nil { + pipelineStr = fmt.Sprintf(videoSrc+"video/x-raw,format=I420 ! x264enc bframes=0 key-int-max=60 byte-stream=true tune=zerolatency speed-preset=veryfast ! video/x-h264,stream-format=byte-stream"+pipelineStr, pipelineDevice) + + if err := CheckPlugins([]string{"x264"}); err != nil { + return nil, err + } + } + } + case webrtc.Opus: + // https://gstreamer.freedesktop.org/documentation/opus/opusenc.html + // gstreamer1.0-plugins-base + // opusenc + if err := CheckPlugins([]string{"pulseaudio", "opus"}); err != nil { + return nil, err + } + + clockRate = audioClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(audioSrc+"opusenc"+pipelineStr, pipelineDevice) + } + case webrtc.G722: + // https://gstreamer.freedesktop.org/documentation/libav/avenc_g722.html?gi-language=c + // gstreamer1.0-libav + // avenc_g722 + if err := CheckPlugins([]string{"pulseaudio", "libav"}); err != nil { + return nil, err + } + + clockRate = audioClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(audioSrc+"avenc_g722"+pipelineStr, pipelineDevice) + } + case webrtc.PCMU: + // https://gstreamer.freedesktop.org/documentation/mulaw/mulawenc.html?gi-language=c + // gstreamer1.0-plugins-good + // audio/x-raw, rate=8000 ! mulawenc + if err := CheckPlugins([]string{"pulseaudio", "mulaw"}); err != nil { + return nil, err + } + + clockRate = pcmClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(audioSrc+"audio/x-raw, rate=8000 ! mulawenc"+pipelineStr, pipelineDevice) + } + case webrtc.PCMA: + // https://gstreamer.freedesktop.org/documentation/alaw/alawenc.html?gi-language=c + // gstreamer1.0-plugins-good + // audio/x-raw, rate=8000 ! alawenc + if err := CheckPlugins([]string{"pulseaudio", "alaw"}); err != nil { + return nil, err + } + + clockRate = pcmClockRate + + if pipelineSrc != "" { + pipelineStr = fmt.Sprintf(pipelineSrc+pipelineStr, pipelineDevice) + } else { + pipelineStr = fmt.Sprintf(audioSrc+"audio/x-raw, rate=8000 ! alawenc"+pipelineStr, pipelineDevice) + } + default: + return nil, fmt.Errorf("unknown codec %s", codecName) + } + + return CreatePipeline(pipelineStr, codecName, clockRate) +} + +// CreatePipeline creates a GStreamer Pipeline +func CreatePipeline(pipelineStr string, codecName string, clockRate float32) (*Pipeline, error) { + pipelineStrUnsafe := C.CString(pipelineStr) + defer C.free(unsafe.Pointer(pipelineStrUnsafe)) + + pipelinesLock.Lock() + defer pipelinesLock.Unlock() + + p := &Pipeline{ + Pipeline: C.gstreamer_send_create_pipeline(pipelineStrUnsafe), + Sample: make(chan types.Sample), + CodecName: codecName, + ClockRate: clockRate, + Src: pipelineStr, + id: len(pipelines), + } + + pipelines[p.id] = p + return p, nil +} + +// Start starts the GStreamer Pipeline +func (p *Pipeline) Start() { + C.gstreamer_send_start_pipeline(p.Pipeline, C.int(p.id)) +} + +// Play starts the GStreamer Pipeline +func (p *Pipeline) Play() { + C.gstreamer_send_play_pipeline(p.Pipeline) +} + +// Stop stops the GStreamer Pipeline +func (p *Pipeline) Stop() { + C.gstreamer_send_stop_pipeline(p.Pipeline) +} + +// gst-inspect-1.0 +func CheckPlugins(plugins []string) error { + var plugin *C.GstPlugin + for _, pluginstr := range plugins { + plugincstr := C.CString(pluginstr) + plugin = C.gst_registry_find_plugin(registry, plugincstr) + C.free(unsafe.Pointer(plugincstr)) + if plugin == nil { + return fmt.Errorf("required gstreamer plugin %s not found", pluginstr) + } + } + + return nil +} + +//export goHandlePipelineBuffer +func goHandlePipelineBuffer(buffer unsafe.Pointer, bufferLen C.int, duration C.int, pipelineID C.int) { + pipelinesLock.Lock() + pipeline, ok := pipelines[int(pipelineID)] + pipelinesLock.Unlock() + + if ok { + samples := uint32(pipeline.ClockRate * (float32(duration) / 1000000000)) + pipeline.Sample <- types.Sample{Data: C.GoBytes(buffer, bufferLen), Samples: samples} + } else { + fmt.Printf("discarding buffer, no pipeline with id %d", int(pipelineID)) + } + C.free(buffer) +} diff --git a/internal/gst/gst.h b/internal/gst/gst.h new file mode 100644 index 00000000..7c6965f4 --- /dev/null +++ b/internal/gst/gst.h @@ -0,0 +1,19 @@ +#ifndef GST_H +#define GST_H + +#include +#include +#include +#include + +extern void goHandlePipelineBuffer(void *buffer, int bufferLen, int samples, int pipelineId); + +GstElement *gstreamer_send_create_pipeline(char *pipeline); + +void gstreamer_send_start_pipeline(GstElement *pipeline, int pipelineId); +void gstreamer_send_play_pipeline(GstElement *pipeline); +void gstreamer_send_stop_pipeline(GstElement *pipeline); +void gstreamer_send_start_mainloop(void); +void gstreamer_init(void); + +#endif diff --git a/internal/http/endpoint/endpoint.go b/internal/http/endpoint/endpoint.go new file mode 100644 index 00000000..fcfcb1ae --- /dev/null +++ b/internal/http/endpoint/endpoint.go @@ -0,0 +1,102 @@ +package endpoint + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime/debug" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog/log" +) + +type ( + Endpoint func(http.ResponseWriter, *http.Request) error + + ErrResponse struct { + Status int `json:"status,omitempty"` + Err string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` + Code string `json:"code,omitempty"` + RequestID string `json:"request,omitempty"` + } +) + +func Handle(handler Endpoint) http.HandlerFunc { + fn := func(w http.ResponseWriter, r *http.Request) { + if err := handler(w, r); err != nil { + WriteError(w, r, err) + } + } + + return http.HandlerFunc(fn) +} + +var nonErrorsCodes = map[int]bool{ + 404: true, +} + +func errResponse(input interface{}) *ErrResponse { + var res *ErrResponse + var err interface{} + + switch input.(type) { + case *HandlerError: + e := input.(*HandlerError) + res = &ErrResponse{ + Status: e.Status, + Err: http.StatusText(e.Status), + Message: e.Message, + } + err = e.Err + default: + res = &ErrResponse{ + Status: http.StatusInternalServerError, + Err: http.StatusText(http.StatusInternalServerError), + } + err = input + } + + if err != nil { + switch err.(type) { + case *error: + e := err.(error) + res.Details = e.Error() + break + default: + res.Details = fmt.Sprintf("%+v", err) + break + } + } + + return res +} + +func WriteError(w http.ResponseWriter, r *http.Request, err interface{}) { + hlog := log.With(). + Str("module", "http"). + Logger() + + res := errResponse(err) + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + res.RequestID = reqID + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(res.Status) + + if err := json.NewEncoder(w).Encode(res); err != nil { + hlog.Warn().Err(err).Msg("Failed writing json error response") + } + + if !nonErrorsCodes[res.Status] { + logEntry := middleware.GetLogEntry(r) + if logEntry != nil { + logEntry.Panic(err, debug.Stack()) + } else { + hlog.Error().Str("stack", string(debug.Stack())).Msgf("%+v", err) + } + } +} diff --git a/internal/http/endpoint/error.go b/internal/http/endpoint/error.go new file mode 100644 index 00000000..c92000e3 --- /dev/null +++ b/internal/http/endpoint/error.go @@ -0,0 +1,17 @@ +package endpoint + +import "fmt" + +type HandlerError struct { + Status int + Message string + Err error +} + +func (e *HandlerError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s", e.Message, e.Err.Error()) + } + + return e.Message +} diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 00000000..e8cec82c --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,87 @@ +package http + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/go-chi/chi" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/http/endpoint" + "n.eko.moe/neko/internal/http/middleware" + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/config" +) + +type Server struct { + logger zerolog.Logger + router *chi.Mux + http *http.Server + conf *config.Server +} + +func New(conf *config.Server, webSocketHandler types.WebSocketHandler) *Server { + logger := log.With().Str("module", "webrtc").Logger() + + router := chi.NewRouter() + // router.Use(middleware.Recoverer) // Recover from panics without crashing server + router.Use(middleware.RequestID) // Create a request ID for each request + router.Use(middleware.Logger) // Log API request calls + + router.Get("/ws", func(w http.ResponseWriter, r *http.Request) { + webSocketHandler.Upgrade(w, r) + }) + + fs := http.FileServer(http.Dir(conf.Static)) + router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + if _, err := os.Stat(conf.Static + r.RequestURI); os.IsNotExist(err) { + http.StripPrefix(r.RequestURI, fs).ServeHTTP(w, r) + } else { + fs.ServeHTTP(w, r) + } + }) + + router.NotFound(endpoint.Handle(func(w http.ResponseWriter, r *http.Request) error { + return &endpoint.HandlerError{ + Status: http.StatusNotFound, + Message: fmt.Sprintf("file '%s' is not found", r.RequestURI), + } + })) + + server := &http.Server{ + Addr: conf.Bind, + Handler: router, + } + + return &Server{ + logger: logger, + router: router, + http: server, + conf: conf, + } +} + +func (s *Server) Start() { + if s.conf.Cert != "" && s.conf.Key != "" { + go func() { + if err := s.http.ListenAndServeTLS(s.conf.Cert, s.conf.Key); err != http.ErrServerClosed { + s.logger.Panic().Err(err).Msg("unable to start https server") + } + }() + s.logger.Info().Msgf("https listening on %s", s.http.Addr) + } else { + go func() { + if err := s.http.ListenAndServe(); err != http.ErrServerClosed { + s.logger.Panic().Err(err).Msg("unable to start http server") + } + }() + s.logger.Warn().Msgf("http listening on %s", s.http.Addr) + } +} + +func (s *Server) Shutdown() error { + return s.http.Shutdown(context.Background()) +} diff --git a/internal/http/middleware/logger.go b/internal/http/middleware/logger.go new file mode 100644 index 00000000..b20ea9ac --- /dev/null +++ b/internal/http/middleware/logger.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog/log" +) + +func Logger(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + req := map[string]interface{}{} + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + req["id"] = reqID + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + req["scheme"] = scheme + req["proto"] = r.Proto + req["method"] = r.Method + req["remote"] = r.RemoteAddr + req["agent"] = r.UserAgent() + req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + + fields := map[string]interface{}{} + fields["req"] = req + + entry := &entry{ + fields: fields, + } + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + t1 := time.Now() + + defer func() { + entry.Write(ww.Status(), ww.BytesWritten(), time.Since(t1)) + }() + + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) +} + +type entry struct { + fields map[string]interface{} + errors []map[string]interface{} +} + +func (e *entry) Write(status, bytes int, elapsed time.Duration) { + res := map[string]interface{}{} + res["time"] = time.Now().UTC().Format(time.RFC1123) + res["status"] = status + res["bytes"] = bytes + res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0 + + e.fields["res"] = res + e.fields["module"] = "http" + + if len(e.errors) > 0 { + e.fields["errors"] = e.errors + log.Error().Fields(e.fields).Msgf("request failed (%d)", status) + } else { + log.Debug().Fields(e.fields).Msgf("request complete (%d)", status) + } +} + +func (e *entry) Panic(v interface{}, stack []byte) { + err := map[string]interface{}{} + err["message"] = fmt.Sprintf("%+v", v) + err["stack"] = string(stack) + + e.errors = append(e.errors, err) +} diff --git a/internal/http/middleware/middleware.go b/internal/http/middleware/middleware.go new file mode 100644 index 00000000..1d0ca508 --- /dev/null +++ b/internal/http/middleware/middleware.go @@ -0,0 +1,12 @@ +package middleware + +// contextKey is a value for use with context.WithValue. It's used as +// a pointer so it fits in an interface{} without allocation. This technique +// for defining context keys was copied from Go 1.7's new use of context in net/http. +type ctxKey struct { + name string +} + +func (k *ctxKey) String() string { + return "neko/ctx/" + k.name +} diff --git a/internal/http/middleware/recover.go b/internal/http/middleware/recover.go new file mode 100644 index 00000000..9a254889 --- /dev/null +++ b/internal/http/middleware/recover.go @@ -0,0 +1,24 @@ +package middleware + +// The original work was derived from Goji's middleware, source: +// https://github.com/zenazn/goji/tree/master/web/middleware + +import ( + "net/http" + + "n.eko.moe/neko/internal/http/endpoint" +) + +func Recoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + endpoint.WriteError(w, r, rvr) + } + }() + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} diff --git a/internal/http/middleware/request.go b/internal/http/middleware/request.go new file mode 100644 index 00000000..c9634bf8 --- /dev/null +++ b/internal/http/middleware/request.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + "sync/atomic" +) + +// Key to use when setting the request ID. +type ctxKeyRequestID int + +// RequestIDKey is the key that holds the unique request ID in a request context. +const RequestIDKey ctxKeyRequestID = 0 + +var prefix string +var reqid uint64 + +// A quick note on the statistics here: we're trying to calculate the chance that +// two randomly generated base62 prefixes will collide. We use the formula from +// http://en.wikipedia.org/wiki/Birthday_problem +// +// P[m, n] \approx 1 - e^{-m^2/2n} +// +// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server +// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$ +// +// For a $k$ character base-62 identifier, we have $n(k) = 62^k$ +// +// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for +// our purposes, and is surely more than anyone would ever need in practice -- a +// process that is rebooted a handful of times a day for a hundred years has less +// than a millionth of a percent chance of generating two colliding IDs. + +func init() { + hostname, err := os.Hostname() + if hostname == "" || err != nil { + hostname = "localhost" + } + var buf [12]byte + var b64 string + for len(b64) < 10 { + rand.Read(buf[:]) + b64 = base64.StdEncoding.EncodeToString(buf[:]) + b64 = strings.NewReplacer("+", "", "/", "").Replace(b64) + } + + prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10]) +} + +// RequestID is a middleware that injects a request ID into the context of each +// request. A request ID is a string of the form "host.example.com/random-0001", +// where "random" is a base62 random string that uniquely identifies this go +// process, and where the last number is an atomically incremented request +// counter. +func RequestID(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + requestID := r.Header.Get("X-Request-Id") + if requestID == "" { + myid := atomic.AddUint64(&reqid, 1) + requestID = fmt.Sprintf("%s-%06d", prefix, myid) + } + ctx = context.WithValue(ctx, RequestIDKey, requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) +} + +// GetReqID returns a request ID from the given context if one is present. +// Returns the empty string if a request ID cannot be found. +func GetReqID(ctx context.Context) string { + if ctx == nil { + return "" + } + if reqID, ok := ctx.Value(RequestIDKey).(string); ok { + return reqID + } + return "" +} + +// NextRequestID generates the next request ID in the sequence. +func NextRequestID() uint64 { + return atomic.AddUint64(&reqid, 1) +} diff --git a/internal/http/response/response.go b/internal/http/response/response.go new file mode 100644 index 00000000..ea53baa0 --- /dev/null +++ b/internal/http/response/response.go @@ -0,0 +1,32 @@ +package response + +import ( + "encoding/json" + "net/http" + + "n.eko.moe/neko/internal/http/endpoint" +) + +// JSON encodes data to rw in JSON format. Returns a pointer to a +// HandlerError if encoding fails. +func JSON(w http.ResponseWriter, data interface{}, status int) error { + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(w).Encode(data) + if err != nil { + return &endpoint.HandlerError{ + Status: http.StatusInternalServerError, + Message: "unable to write JSON response", + Err: err, + } + } + + return nil +} + +// Empty merely sets the response code to NoContent (204). +func Empty(w http.ResponseWriter) error { + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/internal/remote/manager.go b/internal/remote/manager.go new file mode 100644 index 00000000..3ed2f005 --- /dev/null +++ b/internal/remote/manager.go @@ -0,0 +1,235 @@ +package remote + +import ( + "fmt" + "time" + + "github.com/kataras/go-events" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "n.eko.moe/neko/internal/gst" + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/config" + "n.eko.moe/neko/internal/xorg" +) + +type RemoteManager struct { + logger zerolog.Logger + video *gst.Pipeline + audio *gst.Pipeline + config *config.Remote + broadcast types.BroadcastManager + cleanup *time.Ticker + shutdown chan bool + emmiter events.EventEmmiter + streaming bool +} + +func New(config *config.Remote, broadcast types.BroadcastManager) *RemoteManager { + return &RemoteManager{ + logger: log.With().Str("module", "remote").Logger(), + cleanup: time.NewTicker(1 * time.Second), + shutdown: make(chan bool), + emmiter: events.New(), + config: config, + broadcast: broadcast, + streaming: false, + } +} + +func (manager *RemoteManager) VideoCodec() string { + return manager.config.VideoCodec +} + +func (manager *RemoteManager) AudioCodec() string { + return manager.config.AudioCodec +} + +func (manager *RemoteManager) Start() { + xorg.Display(manager.config.Display) + + if !xorg.ValidScreenSize(manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate) { + manager.logger.Warn().Msgf("invalid screen option %dx%d@%d", manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate) + } else if err := xorg.ChangeScreenSize(manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate); err != nil { + manager.logger.Warn().Err(err).Msg("unable to change screen size") + } + + manager.createPipelines() + manager.broadcast.Start() + + go func() { + defer func() { + manager.logger.Info().Msg("shutdown") + }() + + for { + select { + case <-manager.shutdown: + return + case sample := <-manager.video.Sample: + manager.emmiter.Emit("video", sample) + case sample := <-manager.audio.Sample: + manager.emmiter.Emit("audio", sample) + case <-manager.cleanup.C: + xorg.CheckKeys(time.Second * 10) + } + } + }() +} + +func (manager *RemoteManager) Shutdown() error { + manager.logger.Info().Msgf("remote shutting down") + manager.video.Stop() + manager.audio.Stop() + manager.broadcast.Stop() + + manager.cleanup.Stop() + manager.shutdown <- true + return nil +} + +func (manager *RemoteManager) OnVideoFrame(listener func(sample types.Sample)) { + manager.emmiter.On("video", func(payload ...interface{}) { + listener(payload[0].(types.Sample)) + }) +} + +func (manager *RemoteManager) OnAudioFrame(listener func(sample types.Sample)) { + manager.emmiter.On("audio", func(payload ...interface{}) { + listener(payload[0].(types.Sample)) + }) +} + +func (manager *RemoteManager) StartStream() { + manager.createPipelines() + + manager.logger.Info(). + Str("video_display", manager.config.Display). + Str("video_codec", manager.config.VideoCodec). + Str("audio_device", manager.config.Device). + Str("audio_codec", manager.config.AudioCodec). + Str("audio_pipeline_src", manager.audio.Src). + Str("video_pipeline_src", manager.video.Src). + Str("screen_resolution", fmt.Sprintf("%dx%d@%d", manager.config.ScreenWidth, manager.config.ScreenHeight, manager.config.ScreenRate)). + Msgf("Pipelines starting...") + + manager.video.Start() + manager.audio.Start() + manager.streaming = true +} + +func (manager *RemoteManager) StopStream() { + manager.logger.Info().Msgf("Pipelines shutting down...") + manager.video.Stop() + manager.audio.Stop() + manager.streaming = false +} + +func (manager *RemoteManager) Streaming() bool { + return manager.streaming +} + +func (manager *RemoteManager) createPipelines() { + var err error + manager.video, err = gst.CreateAppPipeline( + manager.config.VideoCodec, + manager.config.Display, + manager.config.VideoParams, + ) + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create video pipeline") + } + + manager.audio, err = gst.CreateAppPipeline( + manager.config.AudioCodec, + manager.config.Device, + manager.config.AudioParams, + ) + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create audio pipeline") + } +} + +func (manager *RemoteManager) ChangeResolution(width int, height int, rate int) error { + if !xorg.ValidScreenSize(width, height, rate) { + return fmt.Errorf("unknown configuration") + } + + manager.video.Stop() + manager.broadcast.Stop() + + defer func() { + manager.video.Start() + manager.broadcast.Start() + + manager.logger.Info().Msg("starting video pipeline...") + }() + + if err := xorg.ChangeScreenSize(width, height, rate); err != nil { + return err + } + + var err error + manager.video, err = gst.CreateAppPipeline( + manager.config.VideoCodec, + manager.config.Display, + manager.config.VideoParams, + ) + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create new video pipeline") + } + + return nil +} + +func (manager *RemoteManager) Move(x, y int) { + xorg.Move(x, y) +} + +func (manager *RemoteManager) Scroll(x, y int) { + xorg.Scroll(x, y) +} + +func (manager *RemoteManager) ButtonDown(code int) error { + return xorg.ButtonDown(code) +} + +func (manager *RemoteManager) KeyDown(code uint64) error { + return xorg.KeyDown(code) +} + +func (manager *RemoteManager) ButtonUp(code int) error { + return xorg.ButtonUp(code) +} + +func (manager *RemoteManager) KeyUp(code uint64) error { + return xorg.KeyUp(code) +} + +func (manager *RemoteManager) ReadClipboard() string { + return xorg.ReadClipboard() +} + +func (manager *RemoteManager) WriteClipboard(data string) { + xorg.WriteClipboard(data) +} + +func (manager *RemoteManager) ResetKeys() { + xorg.ResetKeys() +} + +func (manager *RemoteManager) ScreenConfigurations() map[int]types.ScreenConfiguration { + return xorg.ScreenConfigurations +} + +func (manager *RemoteManager) GetScreenSize() *types.ScreenSize { + return xorg.GetScreenSize() +} + +func (manager *RemoteManager) SetKeyboardLayout(layout string) { + xorg.SetKeyboardLayout(layout) +} + +func (manager *RemoteManager) SetKeyboardModifiers(NumLock int, CapsLock int, ScrollLock int) { + xorg.SetKeyboardModifiers(NumLock, CapsLock, ScrollLock) +} diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 00000000..8e51bc5a --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,189 @@ +package session + +import ( + "fmt" + + "github.com/kataras/go-events" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/utils" +) + +func New(remote types.RemoteManager) *SessionManager { + return &SessionManager{ + logger: log.With().Str("module", "session").Logger(), + host: "", + remote: remote, + members: make(map[string]*Session), + emmiter: events.New(), + } +} + +type SessionManager struct { + logger zerolog.Logger + host string + remote types.RemoteManager + members map[string]*Session + emmiter events.EventEmmiter +} + +func (manager *SessionManager) New(id string, admin bool, socket types.WebSocket) types.Session { + session := &Session{ + id: id, + admin: admin, + manager: manager, + socket: socket, + logger: manager.logger.With().Str("id", id).Logger(), + connected: false, + } + + manager.members[id] = session + manager.emmiter.Emit("created", id, session) + + if manager.remote.Streaming() != true && len(manager.members) > 0 { + manager.remote.StartStream() + } + + return session +} + +func (manager *SessionManager) HasHost() bool { + return manager.host != "" +} + +func (manager *SessionManager) IsHost(id string) bool { + return manager.host == id +} + +func (manager *SessionManager) SetHost(id string) error { + _, ok := manager.members[id] + if ok { + manager.host = id + manager.emmiter.Emit("host", id) + return nil + } + return fmt.Errorf("invalid session id %s", id) +} + +func (manager *SessionManager) GetHost() (types.Session, bool) { + host, ok := manager.members[manager.host] + return host, ok +} + +func (manager *SessionManager) ClearHost() { + id := manager.host + manager.host = "" + manager.emmiter.Emit("host_cleared", id) +} + +func (manager *SessionManager) Has(id string) bool { + _, ok := manager.members[id] + return ok +} + +func (manager *SessionManager) Get(id string) (types.Session, bool) { + session, ok := manager.members[id] + return session, ok +} + +func (manager *SessionManager) Admins() []*types.Member { + members := []*types.Member{} + for _, session := range manager.members { + if !session.connected || !session.admin { + continue + } + + member := session.Member() + if member != nil { + members = append(members, member) + } + } + return members +} + +func (manager *SessionManager) Members() []*types.Member { + members := []*types.Member{} + for _, session := range manager.members { + if !session.connected { + continue + } + + member := session.Member() + if member != nil { + members = append(members, member) + } + } + return members +} + +func (manager *SessionManager) Destroy(id string) error { + session, ok := manager.members[id] + if ok { + err := session.destroy() + delete(manager.members, id) + + if manager.remote.Streaming() != false && len(manager.members) <= 0 { + manager.remote.StopStream() + } + + manager.emmiter.Emit("destroyed", id, session) + return err + } + + return nil +} + +func (manager *SessionManager) Clear() error { + return nil +} + +func (manager *SessionManager) Broadcast(v interface{}, exclude interface{}) error { + for id, session := range manager.members { + if !session.connected { + continue + } + + if exclude != nil { + if in, _ := utils.ArrayIn(id, exclude); in { + continue + } + } + + if err := session.Send(v); err != nil { + return err + } + } + return nil +} + +func (manager *SessionManager) OnHost(listener func(id string)) { + manager.emmiter.On("host", func(payload ...interface{}) { + listener(payload[0].(string)) + }) +} + +func (manager *SessionManager) OnHostCleared(listener func(id string)) { + manager.emmiter.On("host_cleared", func(payload ...interface{}) { + listener(payload[0].(string)) + }) +} + +func (manager *SessionManager) OnDestroy(listener func(id string, session types.Session)) { + manager.emmiter.On("destroyed", func(payload ...interface{}) { + listener(payload[0].(string), payload[1].(*Session)) + }) +} + +func (manager *SessionManager) OnCreated(listener func(id string, session types.Session)) { + manager.emmiter.On("created", func(payload ...interface{}) { + listener(payload[0].(string), payload[1].(*Session)) + }) +} + +func (manager *SessionManager) OnConnected(listener func(id string, session types.Session)) { + manager.emmiter.On("connected", func(payload ...interface{}) { + listener(payload[0].(string), payload[1].(*Session)) + }) +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 00000000..b9733a78 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,137 @@ +package session + +import ( + "sync" + + "github.com/rs/zerolog" + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +type Session struct { + logger zerolog.Logger + id string + name string + admin bool + muted bool + connected bool + manager *SessionManager + socket types.WebSocket + peer types.Peer + mu sync.Mutex +} + +func (session *Session) ID() string { + return session.id +} + +func (session *Session) Name() string { + return session.name +} + +func (session *Session) Admin() bool { + return session.admin +} + +func (session *Session) Muted() bool { + return session.muted +} + +func (session *Session) Connected() bool { + return session.connected +} + +func (session *Session) Address() string { + if session.socket == nil { + return "" + } + return session.socket.Address() +} + +func (session *Session) Member() *types.Member { + return &types.Member{ + ID: session.id, + Name: session.name, + Admin: session.admin, + Muted: session.muted, + } +} + +func (session *Session) SetMuted(muted bool) { + session.muted = muted +} + +func (session *Session) SetName(name string) error { + session.name = name + return nil +} + +func (session *Session) SetSocket(socket types.WebSocket) error { + session.socket = socket + return nil +} + +func (session *Session) SetPeer(peer types.Peer) error { + session.peer = peer + return nil +} + +func (session *Session) SetConnected(connected bool) error { + session.connected = connected + if connected { + session.manager.emmiter.Emit("connected", session.id, session) + } + return nil +} + +func (session *Session) Kick(reason string) error { + if session.socket == nil { + return nil + } + if err := session.socket.Send(&message.Disconnect{ + Event: event.SYSTEM_DISCONNECT, + Message: reason, + }); err != nil { + return err + } + + return session.destroy() +} + +func (session *Session) Send(v interface{}) error { + if session.socket == nil { + return nil + } + return session.socket.Send(v) +} + +func (session *Session) Write(v interface{}) error { + if session.socket == nil { + return nil + } + return session.socket.Send(v) +} + +func (session *Session) SignalAnswer(sdp string) error { + if session.peer == nil { + return nil + } + return session.peer.SignalAnswer(sdp) +} + +func (session *Session) destroy() error { + if session.socket != nil { + if err := session.socket.Destroy(); err != nil { + return err + } + } + + if session.peer != nil { + if err := session.peer.Destroy(); err != nil { + return err + } + } + + return nil +} diff --git a/internal/types/broadcast.go b/internal/types/broadcast.go new file mode 100644 index 00000000..fee3fd52 --- /dev/null +++ b/internal/types/broadcast.go @@ -0,0 +1,10 @@ +package types + +type BroadcastManager interface { + Start() + Stop() + IsActive() bool + Create(url string) + Destroy() + GetUrl() string +} diff --git a/internal/types/config/broadcast.go b/internal/types/config/broadcast.go new file mode 100644 index 00000000..22ef94ac --- /dev/null +++ b/internal/types/config/broadcast.go @@ -0,0 +1,23 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Broadcast struct { + Pipeline string +} + +func (Broadcast) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().String("broadcast_pipeline", "", "audio codec parameters to use for broadcasting") + if err := viper.BindPFlag("broadcast_pipeline", cmd.PersistentFlags().Lookup("broadcast_pipeline")); err != nil { + return err + } + + return nil +} + +func (s *Broadcast) Set() { + s.Pipeline = viper.GetString("broadcast_pipeline") +} diff --git a/internal/types/config/config.go b/internal/types/config/config.go new file mode 100644 index 00000000..68a2abc2 --- /dev/null +++ b/internal/types/config/config.go @@ -0,0 +1,8 @@ +package config + +import "github.com/spf13/cobra" + +type Config interface { + Init(cmd *cobra.Command) error + Set() +} diff --git a/internal/types/config/remote.go b/internal/types/config/remote.go new file mode 100644 index 00000000..045a840b --- /dev/null +++ b/internal/types/config/remote.go @@ -0,0 +1,136 @@ +package config + +import ( + "regexp" + "strconv" + + "github.com/pion/webrtc/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Remote struct { + Display string + Device string + AudioCodec string + AudioParams string + VideoCodec string + VideoParams string + ScreenWidth int + ScreenHeight int + ScreenRate int +} + +func (Remote) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().String("display", ":99.0", "XDisplay to capture") + if err := viper.BindPFlag("display", cmd.PersistentFlags().Lookup("display")); err != nil { + return err + } + + cmd.PersistentFlags().String("device", "auto_null.monitor", "audio device to capture") + if err := viper.BindPFlag("device", cmd.PersistentFlags().Lookup("device")); err != nil { + return err + } + + cmd.PersistentFlags().String("audio", "", "audio codec parameters to use for streaming") + if err := viper.BindPFlag("audio", cmd.PersistentFlags().Lookup("audio")); err != nil { + return err + } + + cmd.PersistentFlags().String("video", "", "video codec parameters to use for streaming") + if err := viper.BindPFlag("video", cmd.PersistentFlags().Lookup("video")); err != nil { + return err + } + + cmd.PersistentFlags().String("screen", "1280x720@30", "default screen resolution and framerate") + if err := viper.BindPFlag("screen", cmd.PersistentFlags().Lookup("screen")); err != nil { + return err + } + + // video codecs + cmd.PersistentFlags().Bool("vp8", false, "use VP8 video codec") + if err := viper.BindPFlag("vp8", cmd.PersistentFlags().Lookup("vp8")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("vp9", false, "use VP9 video codec") + if err := viper.BindPFlag("vp9", cmd.PersistentFlags().Lookup("vp9")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("h264", false, "use H264 video codec") + if err := viper.BindPFlag("h264", cmd.PersistentFlags().Lookup("h264")); err != nil { + return err + } + + // audio codecs + cmd.PersistentFlags().Bool("opus", false, "use Opus audio codec") + if err := viper.BindPFlag("opus", cmd.PersistentFlags().Lookup("opus")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("g722", false, "use G722 audio codec") + if err := viper.BindPFlag("g722", cmd.PersistentFlags().Lookup("g722")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("pcmu", false, "use PCMU audio codec") + if err := viper.BindPFlag("pcmu", cmd.PersistentFlags().Lookup("pcmu")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("pcma", false, "use PCMA audio codec") + if err := viper.BindPFlag("pcma", cmd.PersistentFlags().Lookup("pcma")); err != nil { + return err + } + + return nil +} + +func (s *Remote) Set() { + videoCodec := webrtc.VP8 + if viper.GetBool("vp8") { + videoCodec = webrtc.VP8 + } else if viper.GetBool("vp9") { + videoCodec = webrtc.VP9 + } else if viper.GetBool("h264") { + videoCodec = webrtc.H264 + } + + audioCodec := webrtc.Opus + if viper.GetBool("opus") { + audioCodec = webrtc.Opus + } else if viper.GetBool("g722") { + audioCodec = webrtc.G722 + } else if viper.GetBool("pcmu") { + audioCodec = webrtc.PCMU + } else if viper.GetBool("pcma") { + audioCodec = webrtc.PCMA + } + + s.Device = viper.GetString("device") + s.AudioCodec = audioCodec + s.AudioParams = viper.GetString("audio") + s.Display = viper.GetString("display") + s.VideoCodec = videoCodec + s.VideoParams = viper.GetString("video") + + s.ScreenWidth = 1280 + s.ScreenHeight = 720 + s.ScreenRate = 30 + + r := regexp.MustCompile(`([0-9]{1,4})x([0-9]{1,4})@([0-9]{1,3})`) + res := r.FindStringSubmatch(viper.GetString("screen")) + + if len(res) > 0 { + width, err1 := strconv.ParseInt(res[1], 10, 64) + height, err2 := strconv.ParseInt(res[2], 10, 64) + rate, err3 := strconv.ParseInt(res[3], 10, 64) + + if err1 == nil && err2 == nil && err3 == nil { + s.ScreenWidth = int(width) + s.ScreenHeight = int(height) + s.ScreenRate = int(rate) + } + } +} diff --git a/internal/types/config/root.go b/internal/types/config/root.go new file mode 100644 index 00000000..0223d7b2 --- /dev/null +++ b/internal/types/config/root.go @@ -0,0 +1,37 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Root struct { + Debug bool + Logs bool + CfgFile string +} + +func (Root) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode") + if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil { + return err + } + + cmd.PersistentFlags().BoolP("logs", "l", false, "save logs to file") + if err := viper.BindPFlag("logs", cmd.PersistentFlags().Lookup("logs")); err != nil { + return err + } + + cmd.PersistentFlags().String("config", "", "configuration file path") + if err := viper.BindPFlag("config", cmd.PersistentFlags().Lookup("config")); err != nil { + return err + } + + return nil +} + +func (s *Root) Set() { + s.Logs = viper.GetBool("logs") + s.Debug = viper.GetBool("debug") + s.CfgFile = viper.GetString("config") +} diff --git a/internal/types/config/server.go b/internal/types/config/server.go new file mode 100644 index 00000000..fcb4b912 --- /dev/null +++ b/internal/types/config/server.go @@ -0,0 +1,44 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type Server struct { + Cert string + Key string + Bind string + Static string +} + +func (Server) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().String("bind", "127.0.0.1:8080", "address/port/socket to serve neko") + if err := viper.BindPFlag("bind", cmd.PersistentFlags().Lookup("bind")); err != nil { + return err + } + + cmd.PersistentFlags().String("cert", "", "path to the SSL cert used to secure the neko server") + if err := viper.BindPFlag("cert", cmd.PersistentFlags().Lookup("cert")); err != nil { + return err + } + + cmd.PersistentFlags().String("key", "", "path to the SSL key used to secure the neko server") + if err := viper.BindPFlag("key", cmd.PersistentFlags().Lookup("key")); err != nil { + return err + } + + cmd.PersistentFlags().String("static", "./www", "path to neko client files to serve") + if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil { + return err + } + + return nil +} + +func (s *Server) Set() { + s.Cert = viper.GetString("cert") + s.Key = viper.GetString("key") + s.Bind = viper.GetString("bind") + s.Static = viper.GetString("static") +} diff --git a/internal/types/config/webrtc.go b/internal/types/config/webrtc.go new file mode 100644 index 00000000..5d76832c --- /dev/null +++ b/internal/types/config/webrtc.go @@ -0,0 +1,79 @@ +package config + +import ( + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "n.eko.moe/neko/internal/utils" +) + +type WebRTC struct { + ICELite bool + ICEServers []string + EphemeralMin uint16 + EphemeralMax uint16 + NAT1To1IPs []string +} + +func (WebRTC) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().String("epr", "59000-59100", "limits the pool of ephemeral ports that ICE UDP connections can allocate from") + if err := viper.BindPFlag("epr", cmd.PersistentFlags().Lookup("epr")); err != nil { + return err + } + + cmd.PersistentFlags().StringSlice("nat1to1", []string{}, "sets a list of external IP addresses of 1:1 (D)NAT and a candidate type for which the external IP address is used") + if err := viper.BindPFlag("nat1to1", cmd.PersistentFlags().Lookup("nat1to1")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("icelite", false, "configures whether or not the ice agent should be a lite agent") + if err := viper.BindPFlag("icelite", cmd.PersistentFlags().Lookup("icelite")); err != nil { + return err + } + + cmd.PersistentFlags().StringSlice("iceserver", []string{"stun:stun.l.google.com:19302"}, "describes a single STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer") + if err := viper.BindPFlag("iceserver", cmd.PersistentFlags().Lookup("iceserver")); err != nil { + return err + } + + return nil +} + +func (s *WebRTC) Set() { + s.ICELite = viper.GetBool("icelite") + s.ICEServers = viper.GetStringSlice("iceserver") + s.NAT1To1IPs = viper.GetStringSlice("nat1to1") + + if len(s.NAT1To1IPs) == 0 { + ip, err := utils.GetIP() + if err == nil { + s.NAT1To1IPs = append(s.NAT1To1IPs, ip) + } + } + + min := uint16(59000) + max := uint16(59100) + epr := viper.GetString("epr") + ports := strings.SplitN(epr, "-", -1) + if len(ports) > 1 { + start, err := strconv.ParseUint(ports[0], 10, 16) + if err == nil { + min = uint16(start) + } + + end, err := strconv.ParseUint(ports[1], 10, 16) + if err == nil { + max = uint16(end) + } + } + + if min > max { + s.EphemeralMin = max + s.EphemeralMax = min + } else { + s.EphemeralMin = min + s.EphemeralMax = max + } +} diff --git a/internal/types/config/websocket.go b/internal/types/config/websocket.go new file mode 100644 index 00000000..099f34bd --- /dev/null +++ b/internal/types/config/websocket.go @@ -0,0 +1,37 @@ +package config + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type WebSocket struct { + Password string + AdminPassword string + Proxy bool +} + +func (WebSocket) Init(cmd *cobra.Command) error { + cmd.PersistentFlags().String("password", "neko", "password for connecting to stream") + if err := viper.BindPFlag("password", cmd.PersistentFlags().Lookup("password")); err != nil { + return err + } + + cmd.PersistentFlags().String("password_admin", "admin", "admin password for connecting to stream") + if err := viper.BindPFlag("password_admin", cmd.PersistentFlags().Lookup("password_admin")); err != nil { + return err + } + + cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode") + if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil { + return err + } + + return nil +} + +func (s *WebSocket) Set() { + s.Password = viper.GetString("password") + s.AdminPassword = viper.GetString("password_admin") + s.Proxy = viper.GetBool("proxy") +} diff --git a/internal/types/event/events.go b/internal/types/event/events.go new file mode 100644 index 00000000..b97c49c8 --- /dev/null +++ b/internal/types/event/events.go @@ -0,0 +1,55 @@ +package event + +const ( + SYSTEM_DISCONNECT = "system/disconnect" +) + +const ( + SIGNAL_ANSWER = "signal/answer" + SIGNAL_PROVIDE = "signal/provide" +) + +const ( + MEMBER_LIST = "member/list" + MEMBER_CONNECTED = "member/connected" + MEMBER_DISCONNECTED = "member/disconnected" +) + +const ( + CONTROL_LOCKED = "control/locked" + CONTROL_RELEASE = "control/release" + CONTROL_REQUEST = "control/request" + CONTROL_REQUESTING = "control/requesting" + CONTROL_GIVE = "control/give" + CONTROL_CLIPBOARD = "control/clipboard" + CONTROL_KEYBOARD = "control/keyboard" +) + +const ( + CHAT_MESSAGE = "chat/message" + CHAT_EMOTE = "chat/emote" +) + +const ( + SCREEN_CONFIGURATIONS = "screen/configurations" + SCREEN_RESOLUTION = "screen/resolution" + SCREEN_SET = "screen/set" +) + +const ( + BORADCAST_STATUS = "broadcast/status" + BORADCAST_CREATE = "broadcast/create" + BORADCAST_DESTROY = "broadcast/destroy" +) + +const ( + ADMIN_BAN = "admin/ban" + ADMIN_KICK = "admin/kick" + ADMIN_LOCK = "admin/lock" + ADMIN_MUTE = "admin/mute" + ADMIN_UNLOCK = "admin/unlock" + ADMIN_UNMUTE = "admin/unmute" + ADMIN_CONTROL = "admin/control" + ADMIN_RELEASE = "admin/release" + ADMIN_GIVE = "admin/give" +) diff --git a/internal/types/keys.go b/internal/types/keys.go new file mode 100644 index 00000000..f8a2fd16 --- /dev/null +++ b/internal/types/keys.go @@ -0,0 +1,14 @@ +package types + +type Button struct { + Name string + Code int + Keysym int +} + +type Key struct { + Name string + Value string + Code int + Keysym int +} diff --git a/internal/types/message/messages.go b/internal/types/message/messages.go new file mode 100644 index 00000000..fcf04f30 --- /dev/null +++ b/internal/types/message/messages.go @@ -0,0 +1,123 @@ +package message + +import ( + "n.eko.moe/neko/internal/types" +) + +type Message struct { + Event string `json:"event"` +} + +type Disconnect struct { + Event string `json:"event"` + Message string `json:"message"` +} + +type SignalProvide struct { + Event string `json:"event"` + ID string `json:"id"` + SDP string `json:"sdp"` + Lite bool `json:"lite"` + ICE []string `json:"ice"` +} + +type SignalAnswer struct { + Event string `json:"event"` + DisplayName string `json:"displayname"` + SDP string `json:"sdp"` +} + +type MembersList struct { + Event string `json:"event"` + Memebers []*types.Member `json:"members"` +} + +type Member struct { + Event string `json:"event"` + *types.Member +} +type MemberDisconnected struct { + Event string `json:"event"` + ID string `json:"id"` +} + +type Clipboard struct { + Event string `json:"event"` + Text string `json:"text"` +} + +type Keyboard struct { + Event string `json:"event"` + Layout *string `json:"layout,omitempty"` + CapsLock *bool `json:"capsLock,omitempty"` + NumLock *bool `json:"numLock,omitempty"` + ScrollLock *bool `json:"scrollLock,omitempty"` +} + +type Control struct { + Event string `json:"event"` + ID string `json:"id"` +} + +type ControlTarget struct { + Event string `json:"event"` + ID string `json:"id"` + Target string `json:"target"` +} + +type ChatReceive struct { + Event string `json:"event"` + Content string `json:"content"` +} + +type ChatSend struct { + Event string `json:"event"` + ID string `json:"id"` + Content string `json:"content"` +} + +type EmoteReceive struct { + Event string `json:"event"` + Emote string `json:"emote"` +} + +type EmoteSend struct { + Event string `json:"event"` + ID string `json:"id"` + Emote string `json:"emote"` +} + +type Admin struct { + Event string `json:"event"` + ID string `json:"id"` +} + +type AdminTarget struct { + Event string `json:"event"` + Target string `json:"target"` + ID string `json:"id"` +} + +type ScreenResolution struct { + Event string `json:"event"` + ID string `json:"id,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Rate int `json:"rate"` +} + +type ScreenConfigurations struct { + Event string `json:"event"` + Configurations map[int]types.ScreenConfiguration `json:"configurations"` +} + +type BroadcastStatus struct { + Event string `json:"event"` + URL string `json:"url"` + IsActive bool `json:"isActive"` +} + +type BroadcastCreate struct { + Event string `json:"event"` + URL string `json:"url"` +} diff --git a/internal/types/remote.go b/internal/types/remote.go new file mode 100644 index 00000000..b437d108 --- /dev/null +++ b/internal/types/remote.go @@ -0,0 +1,27 @@ +package types + +type RemoteManager interface { + VideoCodec() string + AudioCodec() string + Start() + Shutdown() error + OnVideoFrame(listener func(sample Sample)) + OnAudioFrame(listener func(sample Sample)) + StartStream() + StopStream() + Streaming() bool + ChangeResolution(width int, height int, rate int) error + GetScreenSize() *ScreenSize + ScreenConfigurations() map[int]ScreenConfiguration + Move(x, y int) + Scroll(x, y int) + ButtonDown(code int) error + KeyDown(code uint64) error + ButtonUp(code int) error + KeyUp(code uint64) error + ReadClipboard() string + WriteClipboard(data string) + ResetKeys() + SetKeyboardLayout(layout string) + SetKeyboardModifiers(NumLock int, CapsLock int, ScrollLock int) +} diff --git a/internal/types/session.go b/internal/types/session.go new file mode 100644 index 00000000..acdcac64 --- /dev/null +++ b/internal/types/session.go @@ -0,0 +1,48 @@ +package types + +type Member struct { + ID string `json:"id"` + Name string `json:"displayname"` + Admin bool `json:"admin"` + Muted bool `json:"muted"` +} + +type Session interface { + ID() string + Name() string + Admin() bool + Muted() bool + Connected() bool + Member() *Member + SetMuted(muted bool) + SetName(name string) error + SetConnected(connected bool) error + SetSocket(socket WebSocket) error + SetPeer(peer Peer) error + Address() string + Kick(message string) error + Write(v interface{}) error + Send(v interface{}) error + SignalAnswer(sdp string) error +} + +type SessionManager interface { + New(id string, admin bool, socket WebSocket) Session + HasHost() bool + IsHost(id string) bool + SetHost(id string) error + GetHost() (Session, bool) + ClearHost() + Has(id string) bool + Get(id string) (Session, bool) + Members() []*Member + Admins() []*Member + Destroy(id string) error + Clear() error + Broadcast(v interface{}, exclude interface{}) error + OnHost(listener func(id string)) + OnHostCleared(listener func(id string)) + OnDestroy(listener func(id string, session Session)) + OnCreated(listener func(id string, session Session)) + OnConnected(listener func(id string, session Session)) +} diff --git a/internal/types/webrtc.go b/internal/types/webrtc.go new file mode 100644 index 00000000..d0da61e7 --- /dev/null +++ b/internal/types/webrtc.go @@ -0,0 +1,18 @@ +package types + +type Sample struct { + Data []byte + Samples uint32 +} + +type WebRTCManager interface { + Start() + Shutdown() error + CreatePeer(id string, session Session) (string, bool, []string, error) +} + +type Peer interface { + SignalAnswer(sdp string) error + WriteData(v interface{}) error + Destroy() error +} diff --git a/internal/types/webscoket.go b/internal/types/webscoket.go new file mode 100644 index 00000000..d5b63360 --- /dev/null +++ b/internal/types/webscoket.go @@ -0,0 +1,15 @@ +package types + +import "net/http" + +type WebSocket interface { + Address() string + Send(v interface{}) error + Destroy() error +} + +type WebSocketHandler interface { + Start() error + Shutdown() error + Upgrade(w http.ResponseWriter, r *http.Request) error +} diff --git a/internal/types/xorg.go b/internal/types/xorg.go new file mode 100644 index 00000000..dec51611 --- /dev/null +++ b/internal/types/xorg.go @@ -0,0 +1,13 @@ +package types + +type ScreenSize struct { + Width int `json:"width"` + Height int `json:"height"` + Rate int16 `json:"rate"` +} + +type ScreenConfiguration struct { + Width int `json:"width"` + Height int `json:"height"` + Rates map[int]int16 `json:"rates"` +} diff --git a/internal/utils/array.go b/internal/utils/array.go new file mode 100644 index 00000000..b4202735 --- /dev/null +++ b/internal/utils/array.go @@ -0,0 +1,24 @@ +package utils + +import ( + "reflect" +) + +func ArrayIn(val interface{}, array interface{}) (exists bool, index int) { + exists = false + index = -1 + + switch reflect.TypeOf(array).Kind() { + case reflect.Slice: + s := reflect.ValueOf(array) + for i := 0; i < s.Len(); i++ { + if reflect.DeepEqual(val, s.Index(i).Interface()) == true { + index = i + exists = true + return + } + } + } + + return +} diff --git a/internal/utils/color.go b/internal/utils/color.go new file mode 100644 index 00000000..919887c2 --- /dev/null +++ b/internal/utils/color.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + "regexp" +) + +const ( + char = "&" +) + +// Colors: http://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html +var re = regexp.MustCompile(char + `(?m)([0-9]{1,2};[0-9]{1,2}|[0-9]{1,2})`) + +func Color(str string) string { + result := "" + lastIndex := 0 + + for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { + groups := []string{} + for i := 0; i < len(v); i += 2 { + groups = append(groups, str[v[i]:v[i+1]]) + } + + result += str[lastIndex:v[0]] + "\033[" + groups[1] + "m" + lastIndex = v[1] + } + + return result + str[lastIndex:] +} + +func Colorf(format string, a ...interface{}) string { + return fmt.Sprintf(Color(format), a...) +} diff --git a/internal/utils/ip.go b/internal/utils/ip.go new file mode 100644 index 00000000..67c7d38d --- /dev/null +++ b/internal/utils/ip.go @@ -0,0 +1,35 @@ +package utils + +import ( + "bytes" + "io/ioutil" + "net/http" +) + +// dig @resolver1.opendns.com ANY myip.opendns.com +short -4 + +func GetIP() (string, error) { + rsp, err := http.Get("http://checkip.amazonaws.com") + if err != nil { + return "", err + } + defer rsp.Body.Close() + + buf, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return "", err + } + + return string(bytes.TrimSpace(buf)), nil +} + +func ReadUserIP(r *http.Request) string { + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + return IPAddress +} diff --git a/internal/utils/json.go b/internal/utils/json.go new file mode 100644 index 00000000..7ea0494d --- /dev/null +++ b/internal/utils/json.go @@ -0,0 +1,10 @@ +package utils + +import "encoding/json" + +func Unmarshal(in interface{}, raw []byte, callback func() error) error { + if err := json.Unmarshal(raw, &in); err != nil { + return err + } + return callback() +} diff --git a/internal/utils/uid.go b/internal/utils/uid.go new file mode 100644 index 00000000..0f4216a0 --- /dev/null +++ b/internal/utils/uid.go @@ -0,0 +1,98 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "math" +) + +const ( + defaultAlphabet = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // len=64 + defaultSize = 21 + defaultMaskSize = 5 +) + +// Generator function +type Generator func([]byte) (int, error) + +// BytesGenerator is the default bytes generator +var BytesGenerator Generator = rand.Read + +func initMasks(params ...int) []uint { + var size int + if len(params) == 0 { + size = defaultMaskSize + } else { + size = params[0] + } + masks := make([]uint, size) + for i := 0; i < size; i++ { + shift := 3 + i + masks[i] = (2 << uint(shift)) - 1 + } + return masks +} + +func getMask(alphabet string, masks []uint) int { + for i := 0; i < len(masks); i++ { + curr := int(masks[i]) + if curr >= len(alphabet)-1 { + return curr + } + } + return 0 +} + +// GenerateUID is a low-level function to change alphabet and ID size. +func GenerateUID(alphabet string, size int) (string, error) { + if len(alphabet) == 0 || len(alphabet) > 255 { + return "", fmt.Errorf("alphabet must not empty and contain no more than 255 chars. Current len is %d", len(alphabet)) + } + if size <= 0 { + return "", fmt.Errorf("size must be positive integer") + } + + masks := initMasks(size) + mask := getMask(alphabet, masks) + ceilArg := 1.6 * float64(mask*size) / float64(len(alphabet)) + step := int(math.Ceil(ceilArg)) + + id := make([]byte, size) + bytes := make([]byte, step) + for j := 0; ; { + _, err := BytesGenerator(bytes) + if err != nil { + return "", err + } + for i := 0; i < step; i++ { + currByte := bytes[i] & byte(mask) + if currByte < byte(len(alphabet)) { + id[j] = alphabet[currByte] + j++ + if j == size { + return string(id[:size]), nil + } + } + } + } +} + +// NewUID generates secure URL-friendly unique ID. +func NewUID(param ...int) (string, error) { + var size int + if len(param) == 0 { + size = defaultSize + } else { + size = param[0] + } + bytes := make([]byte, size) + _, err := BytesGenerator(bytes) + if err != nil { + return "", err + } + id := make([]byte, size) + for i := 0; i < size; i++ { + id[i] = defaultAlphabet[bytes[i]&63] + } + return string(id[:size]), nil +} diff --git a/internal/webrtc/handle.go b/internal/webrtc/handle.go new file mode 100644 index 00000000..35c4b235 --- /dev/null +++ b/internal/webrtc/handle.go @@ -0,0 +1,137 @@ +package webrtc + +import ( + "bytes" + "encoding/binary" + "strconv" + + "github.com/pion/webrtc/v2" +) + +const OP_MOVE = 0x01 +const OP_SCROLL = 0x02 +const OP_KEY_DOWN = 0x03 +const OP_KEY_UP = 0x04 +const OP_KEY_CLK = 0x05 + +type PayloadHeader struct { + Event uint8 + Length uint16 +} + +type PayloadMove struct { + PayloadHeader + X uint16 + Y uint16 +} + +type PayloadScroll struct { + PayloadHeader + X int16 + Y int16 +} + +type PayloadKey struct { + PayloadHeader + Key uint64 +} + +func (manager *WebRTCManager) handle(id string, msg webrtc.DataChannelMessage) error { + if !manager.sessions.IsHost(id) { + return nil + } + + buffer := bytes.NewBuffer(msg.Data) + header := &PayloadHeader{} + hbytes := make([]byte, 3) + + if _, err := buffer.Read(hbytes); err != nil { + return err + } + + if err := binary.Read(bytes.NewBuffer(hbytes), binary.LittleEndian, header); err != nil { + return err + } + + buffer = bytes.NewBuffer(msg.Data) + + switch header.Event { + case OP_MOVE: + payload := &PayloadMove{} + if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { + return err + } + + manager.remote.Move(int(payload.X), int(payload.Y)) + break + case OP_SCROLL: + payload := &PayloadScroll{} + if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { + return err + } + + manager.logger. + Debug(). + Str("x", strconv.Itoa(int(payload.X))). + Str("y", strconv.Itoa(int(payload.Y))). + Msg("scroll") + + manager.remote.Scroll(int(payload.X), int(payload.Y)) + break + case OP_KEY_DOWN: + payload := &PayloadKey{} + if err := binary.Read(buffer, binary.LittleEndian, payload); err != nil { + return err + } + + if payload.Key < 8 { + err := manager.remote.ButtonDown(int(payload.Key)) + if err != nil { + manager.logger.Warn().Err(err).Msg("button down failed") + return nil + } + + manager.logger.Debug().Msgf("button down %d", payload.Key) + } else { + err := manager.remote.KeyDown(uint64(payload.Key)) + if err != nil { + manager.logger.Warn().Err(err).Msg("key down failed") + return nil + } + + manager.logger.Debug().Msgf("key down %d", payload.Key) + } + + break + case OP_KEY_UP: + payload := &PayloadKey{} + err := binary.Read(buffer, binary.LittleEndian, payload) + if err != nil { + return err + } + + if payload.Key < 8 { + err := manager.remote.ButtonUp(int(payload.Key)) + if err != nil { + manager.logger.Warn().Err(err).Msg("button up failed") + return nil + } + + manager.logger.Debug().Msgf("button up %d", payload.Key) + } else { + err := manager.remote.KeyUp(uint64(payload.Key)) + if err != nil { + manager.logger.Warn().Err(err).Msg("key up failed") + return nil + } + + manager.logger.Debug().Msgf("key up %d", payload.Key) + } + break + case OP_KEY_CLK: + // unused + break + } + + return nil +} diff --git a/internal/webrtc/logger.go b/internal/webrtc/logger.go new file mode 100644 index 00000000..52764867 --- /dev/null +++ b/internal/webrtc/logger.go @@ -0,0 +1,66 @@ +package webrtc + +import ( + "fmt" + "strings" + + "github.com/pion/logging" + "github.com/rs/zerolog" +) + +type nulllog struct{} + +func (l nulllog) Trace(msg string) {} +func (l nulllog) Tracef(format string, args ...interface{}) {} +func (l nulllog) Debug(msg string) {} +func (l nulllog) Debugf(format string, args ...interface{}) {} +func (l nulllog) Info(msg string) {} +func (l nulllog) Infof(format string, args ...interface{}) {} +func (l nulllog) Warn(msg string) {} +func (l nulllog) Warnf(format string, args ...interface{}) {} +func (l nulllog) Error(msg string) {} +func (l nulllog) Errorf(format string, args ...interface{}) {} + +type logger struct { + logger zerolog.Logger + subsystem string +} + +func (l logger) Trace(msg string) { l.logger.Trace().Msg(msg) } +func (l logger) Tracef(format string, args ...interface{}) { l.logger.Trace().Msgf(format, args...) } +func (l logger) Debug(msg string) { l.logger.Debug().Msg(msg) } +func (l logger) Debugf(format string, args ...interface{}) { l.logger.Debug().Msgf(format, args...) } +func (l logger) Info(msg string) { + if strings.Contains(msg, "packetio.Buffer is full") { + //l.logger.Panic().Msg(msg) + return + } + l.logger.Info().Msg(msg) +} +func (l logger) Infof(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + if strings.Contains(msg, "packetio.Buffer is full") { + // l.logger.Panic().Msg(msg) + return + } + l.logger.Info().Msg(msg) +} +func (l logger) Warn(msg string) { l.logger.Warn().Msg(msg) } +func (l logger) Warnf(format string, args ...interface{}) { l.logger.Warn().Msgf(format, args...) } +func (l logger) Error(msg string) { l.logger.Error().Msg(msg) } +func (l logger) Errorf(format string, args ...interface{}) { l.logger.Error().Msgf(format, args...) } + +type loggerFactory struct { + logger zerolog.Logger +} + +func (l loggerFactory) NewLogger(subsystem string) logging.LeveledLogger { + if subsystem == "sctp" { + return nulllog{} + } + + return logger{ + subsystem: subsystem, + logger: l.logger.With().Str("subsystem", subsystem).Logger(), + } +} diff --git a/internal/webrtc/peer.go b/internal/webrtc/peer.go new file mode 100644 index 00000000..f82c4fa7 --- /dev/null +++ b/internal/webrtc/peer.go @@ -0,0 +1,38 @@ +package webrtc + +import ( + "sync" + + "github.com/pion/webrtc/v2" +) + +type Peer struct { + id string + api *webrtc.API + engine *webrtc.MediaEngine + manager *WebRTCManager + settings *webrtc.SettingEngine + connection *webrtc.PeerConnection + configuration *webrtc.Configuration + mu sync.Mutex +} + +func (peer *Peer) SignalAnswer(sdp string) error { + return peer.connection.SetRemoteDescription(webrtc.SessionDescription{SDP: sdp, Type: webrtc.SDPTypeAnswer}) +} + +func (peer *Peer) WriteData(v interface{}) error { + peer.mu.Lock() + defer peer.mu.Unlock() + return nil +} + +func (peer *Peer) Destroy() error { + if peer.connection != nil && peer.connection.ConnectionState() == webrtc.PeerConnectionStateConnected { + if err := peer.connection.Close(); err != nil { + return err + } + } + + return nil +} diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go new file mode 100644 index 00000000..b658ecb8 --- /dev/null +++ b/internal/webrtc/webrtc.go @@ -0,0 +1,201 @@ +package webrtc + +import ( + "fmt" + "io" + "math/rand" + "strings" + + "github.com/pion/webrtc/v2" + "github.com/pion/webrtc/v2/pkg/media" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/config" +) + +func New(sessions types.SessionManager, remote types.RemoteManager, config *config.WebRTC) *WebRTCManager { + return &WebRTCManager{ + logger: log.With().Str("module", "webrtc").Logger(), + remote: remote, + sessions: sessions, + config: config, + } +} + +type WebRTCManager struct { + logger zerolog.Logger + videoTrack *webrtc.Track + audioTrack *webrtc.Track + videoCodec *webrtc.RTPCodec + audioCodec *webrtc.RTPCodec + sessions types.SessionManager + remote types.RemoteManager + config *config.WebRTC +} + +func (manager *WebRTCManager) Start() { + var err error + manager.audioTrack, manager.audioCodec, err = manager.createTrack(manager.remote.AudioCodec()) + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create audio track") + } + + manager.remote.OnAudioFrame(func(sample types.Sample) { + if err := manager.audioTrack.WriteSample(media.Sample(sample)); err != nil && err != io.ErrClosedPipe { + manager.logger.Warn().Err(err).Msg("audio pipeline failed to write") + } + }) + + manager.videoTrack, manager.videoCodec, err = manager.createTrack(manager.remote.VideoCodec()) + if err != nil { + manager.logger.Panic().Err(err).Msg("unable to create video track") + } + + manager.remote.OnVideoFrame(func(sample types.Sample) { + if err := manager.videoTrack.WriteSample(media.Sample(sample)); err != nil && err != io.ErrClosedPipe { + manager.logger.Warn().Err(err).Msg("video pipeline failed to write") + } + }) + + manager.logger.Info(). + Str("ice_lite", fmt.Sprintf("%t", manager.config.ICELite)). + Str("ice_servers", strings.Join(manager.config.ICEServers, ",")). + Str("ephemeral_port_range", fmt.Sprintf("%d-%d", manager.config.EphemeralMin, manager.config.EphemeralMax)). + Str("nat_ips", strings.Join(manager.config.NAT1To1IPs, ",")). + Msgf("webrtc starting") +} + +func (manager *WebRTCManager) Shutdown() error { + manager.logger.Info().Msgf("webrtc shutting down") + return nil +} + +func (manager *WebRTCManager) CreatePeer(id string, session types.Session) (string, bool, []string, error) { + configuration := &webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{ + { + URLs: manager.config.ICEServers, + }, + }, + SDPSemantics: webrtc.SDPSemanticsUnifiedPlanWithFallback, + } + + settings := webrtc.SettingEngine{ + LoggerFactory: loggerFactory{ + logger: manager.logger, + }, + } + + if manager.config.ICELite { + configuration = &webrtc.Configuration{ + SDPSemantics: webrtc.SDPSemanticsUnifiedPlanWithFallback, + } + settings.SetLite(true) + } + + settings.SetEphemeralUDPPortRange(manager.config.EphemeralMin, manager.config.EphemeralMax) + settings.SetNAT1To1IPs(manager.config.NAT1To1IPs, webrtc.ICECandidateTypeHost) + + // Create MediaEngine based off sdp + engine := webrtc.MediaEngine{} + + engine.RegisterCodec(manager.audioCodec) + engine.RegisterCodec(manager.videoCodec) + + // Create API with MediaEngine and SettingEngine + api := webrtc.NewAPI(webrtc.WithMediaEngine(engine), webrtc.WithSettingEngine(settings)) + + // Create new peer connection + connection, err := api.NewPeerConnection(*configuration) + if err != nil { + return "", manager.config.ICELite, manager.config.ICEServers, err + } + + if _, err = connection.AddTransceiverFromTrack(manager.videoTrack, webrtc.RtpTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }); err != nil { + return "", manager.config.ICELite, manager.config.ICEServers, err + } + + if _, err = connection.AddTransceiverFromTrack(manager.audioTrack, webrtc.RtpTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }); err != nil { + return "", manager.config.ICELite, manager.config.ICEServers, err + } + + description, err := connection.CreateOffer(nil) + if err != nil { + return "", manager.config.ICELite, manager.config.ICEServers, err + } + + connection.OnDataChannel(func(d *webrtc.DataChannel) { + d.OnMessage(func(msg webrtc.DataChannelMessage) { + if err = manager.handle(id, msg); err != nil { + manager.logger.Warn().Err(err).Msg("data handle failed") + } + }) + }) + + connection.SetLocalDescription(description) + connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + switch state { + case webrtc.PeerConnectionStateDisconnected: + case webrtc.PeerConnectionStateFailed: + manager.logger.Info().Str("id", id).Msg("peer disconnected") + manager.sessions.Destroy(id) + break + case webrtc.PeerConnectionStateConnected: + manager.logger.Info().Str("id", id).Msg("peer connected") + if err = session.SetConnected(true); err != nil { + manager.logger.Warn().Err(err).Msg("unable to set connected on peer") + manager.sessions.Destroy(id) + } + break + } + }) + + if err := session.SetPeer(&Peer{ + id: id, + api: api, + engine: &engine, + manager: manager, + settings: &settings, + connection: connection, + configuration: configuration, + }); err != nil { + return "", manager.config.ICELite, manager.config.ICEServers, err + } + + return description.SDP, manager.config.ICELite, manager.config.ICEServers, nil +} + +func (m *WebRTCManager) createTrack(codecName string) (*webrtc.Track, *webrtc.RTPCodec, error) { + var codec *webrtc.RTPCodec + switch codecName { + case webrtc.VP8: + codec = webrtc.NewRTPVP8Codec(webrtc.DefaultPayloadTypeVP8, 90000) + case webrtc.VP9: + codec = webrtc.NewRTPVP9Codec(webrtc.DefaultPayloadTypeVP9, 90000) + case webrtc.H264: + codec = webrtc.NewRTPH264Codec(webrtc.DefaultPayloadTypeH264, 90000) + case webrtc.Opus: + codec = webrtc.NewRTPOpusCodec(webrtc.DefaultPayloadTypeOpus, 48000) + case webrtc.G722: + codec = webrtc.NewRTPG722Codec(webrtc.DefaultPayloadTypeG722, 8000) + case webrtc.PCMU: + codec = webrtc.NewRTPPCMUCodec(webrtc.DefaultPayloadTypePCMU, 8000) + case webrtc.PCMA: + codec = webrtc.NewRTPPCMACodec(webrtc.DefaultPayloadTypePCMA, 8000) + default: + return nil, nil, fmt.Errorf("unknown codec %s", codecName) + } + + track, err := webrtc.NewTrack(codec.PayloadType, rand.Uint32(), "stream", "stream", codec) + if err != nil { + return nil, nil, err + } + + return track, codec, nil +} diff --git a/internal/websocket/admin.go b/internal/websocket/admin.go new file mode 100644 index 00000000..1063f279 --- /dev/null +++ b/internal/websocket/admin.go @@ -0,0 +1,298 @@ +package websocket + +import ( + "strings" + + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) adminLock(id string, session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if h.locked { + h.logger.Debug().Msg("server already locked...") + return nil + } + + h.locked = true + + if err := h.sessions.Broadcast( + message.Admin{ + Event: event.ADMIN_LOCK, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK) + return err + } + + return nil +} + +func (h *MessageHandler) adminUnlock(id string, session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if !h.locked { + h.logger.Debug().Msg("server not locked...") + return nil + } + + h.locked = false + + if err := h.sessions.Broadcast( + message.Admin{ + Event: event.ADMIN_UNLOCK, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK) + return err + } + + return nil +} + +func (h *MessageHandler) adminControl(id string, session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + host, ok := h.sessions.GetHost() + + h.sessions.SetHost(id) + + if ok { + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_CONTROL, + ID: id, + Target: host.ID(), + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_CONTROL) + return err + } + } else { + if err := h.sessions.Broadcast( + message.Admin{ + Event: event.ADMIN_CONTROL, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_CONTROL) + return err + } + } + + return nil +} + +func (h *MessageHandler) adminRelease(id string, session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + host, ok := h.sessions.GetHost() + + h.sessions.ClearHost() + + if ok { + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_RELEASE, + ID: id, + Target: host.ID(), + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_RELEASE) + return err + } + } else { + if err := h.sessions.Broadcast( + message.Admin{ + Event: event.ADMIN_RELEASE, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_RELEASE) + return err + } + } + + return nil +} + +func (h *MessageHandler) adminGive(id string, session types.Session, payload *message.Admin) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if !h.sessions.Has(payload.ID) { + h.logger.Debug().Str("id", payload.ID).Msg("user does not exist") + return nil + } + + // set host + h.sessions.SetHost(payload.ID) + + // let everyone know + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.CONTROL_GIVE, + ID: id, + Target: payload.ID, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_LOCKED) + return err + } + + return nil +} + +func (h *MessageHandler) adminMute(id string, session types.Session, payload *message.Admin) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + target, ok := h.sessions.Get(payload.ID) + if !ok { + h.logger.Debug().Str("id", payload.ID).Msg("can't find session id") + return nil + } + + if target.Admin() { + h.logger.Debug().Msg("target is an admin, baling") + return nil + } + + target.SetMuted(true) + + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_MUTE, + Target: target.ID(), + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNMUTE) + return err + } + + return nil +} + +func (h *MessageHandler) adminUnmute(id string, session types.Session, payload *message.Admin) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + target, ok := h.sessions.Get(payload.ID) + if !ok { + h.logger.Debug().Str("id", payload.ID).Msg("can't find target session") + return nil + } + + target.SetMuted(false) + + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_UNMUTE, + Target: target.ID(), + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNMUTE) + return err + } + + return nil +} + +func (h *MessageHandler) adminKick(id string, session types.Session, payload *message.Admin) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + target, ok := h.sessions.Get(payload.ID) + if !ok { + h.logger.Debug().Str("id", payload.ID).Msg("can't find session id") + return nil + } + + if target.Admin() { + h.logger.Debug().Msg("target is an admin, baling") + return nil + } + + if err := target.Kick("kicked"); err != nil { + return err + } + + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_KICK, + Target: target.ID(), + ID: id, + }, []string{payload.ID}); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_KICK) + return err + } + + return nil +} + +func (h *MessageHandler) adminBan(id string, session types.Session, payload *message.Admin) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + target, ok := h.sessions.Get(payload.ID) + if !ok { + h.logger.Debug().Str("id", payload.ID).Msg("can't find session id") + return nil + } + + if target.Admin() { + h.logger.Debug().Msg("target is an admin, baling") + return nil + } + + remote := target.Address() + if remote == "" { + h.logger.Debug().Msg("no remote address, baling") + return nil + } + + address := strings.SplitN(remote, ":", -1) + if len(address[0]) < 1 { + h.logger.Debug().Str("address", remote).Msg("no remote address, baling") + return nil + } + + h.logger.Debug().Str("address", remote).Msg("adding address to banned") + + h.banned[address[0]] = true + + if err := target.Kick("banned"); err != nil { + return err + } + + if err := h.sessions.Broadcast( + message.AdminTarget{ + Event: event.ADMIN_BAN, + Target: target.ID(), + ID: id, + }, []string{payload.ID}); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_BAN) + return err + } + + return nil +} diff --git a/internal/websocket/broadcast.go b/internal/websocket/broadcast.go new file mode 100644 index 00000000..c2d6a884 --- /dev/null +++ b/internal/websocket/broadcast.go @@ -0,0 +1,56 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) boradcastCreate(session types.Session, payload *message.BroadcastCreate) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + h.broadcast.Create(payload.URL) + + if err := h.boradcastStatus(session); err != nil { + return err + } + + return nil +} + +func (h *MessageHandler) boradcastDestroy(session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + h.broadcast.Destroy() + + if err := h.boradcastStatus(session); err != nil { + return err + } + + return nil +} + +func (h *MessageHandler) boradcastStatus(session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if err := session.Send( + message.BroadcastStatus{ + Event: event.BORADCAST_STATUS, + IsActive: h.broadcast.IsActive(), + URL: h.broadcast.GetUrl(), + }); err != nil { + h.logger.Warn().Err(err).Msgf("sending event %s has failed", event.BORADCAST_STATUS) + return err + } + + return nil +} diff --git a/internal/websocket/chat.go b/internal/websocket/chat.go new file mode 100644 index 00000000..8f68b5d6 --- /dev/null +++ b/internal/websocket/chat.go @@ -0,0 +1,41 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) chat(id string, session types.Session, payload *message.ChatReceive) error { + if session.Muted() { + return nil + } + + if err := h.sessions.Broadcast( + message.ChatSend{ + Event: event.CHAT_MESSAGE, + Content: payload.Content, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE) + return err + } + return nil +} + +func (h *MessageHandler) chatEmote(id string, session types.Session, payload *message.EmoteReceive) error { + if session.Muted() { + return nil + } + + if err := h.sessions.Broadcast( + message.EmoteSend{ + Event: event.CHAT_EMOTE, + Emote: payload.Emote, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE) + return err + } + return nil +} diff --git a/internal/websocket/control.go b/internal/websocket/control.go new file mode 100644 index 00000000..442ef3f3 --- /dev/null +++ b/internal/websocket/control.go @@ -0,0 +1,163 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) controlRelease(id string, session types.Session) error { + + // check if session is host + if !h.sessions.IsHost(id) { + h.logger.Debug().Str("id", id).Msg("is not the host") + return nil + } + + // release host + h.logger.Debug().Str("id", id).Msgf("host called %s", event.CONTROL_RELEASE) + h.sessions.ClearHost() + + // tell everyone + if err := h.sessions.Broadcast( + message.Control{ + Event: event.CONTROL_RELEASE, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE) + return err + } + + return nil +} + +func (h *MessageHandler) controlRequest(id string, session types.Session) error { + // check for host + if !h.sessions.HasHost() { + // set host + h.sessions.SetHost(id) + + // let everyone know + if err := h.sessions.Broadcast( + message.Control{ + Event: event.CONTROL_LOCKED, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_LOCKED) + return err + } + + return nil + } + + // get host + host, ok := h.sessions.GetHost() + if ok { + + // tell session there is a host + if err := session.Send(message.Control{ + Event: event.CONTROL_REQUEST, + ID: host.ID(), + }); err != nil { + h.logger.Warn().Err(err).Str("id", id).Msgf("sending event %s has failed", event.CONTROL_REQUEST) + return err + } + + // tell host session wants to be host + if err := host.Send(message.Control{ + Event: event.CONTROL_REQUESTING, + ID: id, + }); err != nil { + h.logger.Warn().Err(err).Str("id", host.ID()).Msgf("sending event %s has failed", event.CONTROL_REQUESTING) + return err + } + } + + return nil +} + +func (h *MessageHandler) controlGive(id string, session types.Session, payload *message.Control) error { + // check if session is host + if !h.sessions.IsHost(id) { + h.logger.Debug().Str("id", id).Msg("is not the host") + return nil + } + + if !h.sessions.Has(payload.ID) { + h.logger.Debug().Str("id", payload.ID).Msg("user does not exist") + return nil + } + + // set host + h.sessions.SetHost(payload.ID) + + // let everyone know + if err := h.sessions.Broadcast( + message.ControlTarget{ + Event: event.CONTROL_GIVE, + ID: id, + Target: payload.ID, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_LOCKED) + return err + } + + return nil +} + +func (h *MessageHandler) controlClipboard(id string, session types.Session, payload *message.Clipboard) error { + // check if session is host + if !h.sessions.IsHost(id) { + h.logger.Debug().Str("id", id).Msg("is not the host") + return nil + } + + h.remote.WriteClipboard(payload.Text) + return nil +} + +func (h *MessageHandler) controlKeyboard(id string, session types.Session, payload *message.Keyboard) error { + // check if session is host + if !h.sessions.IsHost(id) { + h.logger.Debug().Str("id", id).Msg("is not the host") + return nil + } + + // change layout + if payload.Layout != nil { + h.remote.SetKeyboardLayout(*payload.Layout) + } + + // set num lock + var NumLock = 0 + if payload.NumLock == nil { + NumLock = -1 + } else if *payload.NumLock { + NumLock = 1 + } + + // set caps lock + var CapsLock = 0 + if payload.CapsLock == nil { + CapsLock = -1 + } else if *payload.CapsLock { + CapsLock = 1 + } + + // set scroll lock + var ScrollLock = 0 + if payload.ScrollLock == nil { + ScrollLock = -1 + } else if *payload.ScrollLock { + ScrollLock = 1 + } + + h.logger.Debug(). + Int("NumLock", NumLock). + Int("CapsLock", CapsLock). + Int("ScrollLock", ScrollLock). + Msg("setting keyboard modifiers") + + h.remote.SetKeyboardModifiers(NumLock, CapsLock, ScrollLock) + return nil +} diff --git a/internal/websocket/handler.go b/internal/websocket/handler.go new file mode 100644 index 00000000..af829c82 --- /dev/null +++ b/internal/websocket/handler.go @@ -0,0 +1,179 @@ +package websocket + +import ( + "encoding/json" + + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" + "n.eko.moe/neko/internal/utils" +) + +type MessageHandler struct { + logger zerolog.Logger + sessions types.SessionManager + webrtc types.WebRTCManager + remote types.RemoteManager + broadcast types.BroadcastManager + banned map[string]bool + locked bool +} + +func (h *MessageHandler) Connected(id string, socket *WebSocket) (bool, string, error) { + address := socket.Address() + if address == "" { + h.logger.Debug().Msg("no remote address") + } else { + ok, banned := h.banned[address] + if ok && banned { + h.logger.Debug().Str("address", address).Msg("banned") + return false, "banned", nil + } + } + + if h.locked { + session, ok := h.sessions.Get(id) + if !ok || !session.Admin() { + h.logger.Debug().Msg("server locked") + return false, "locked", nil + } + } + + return true, "", nil +} + +func (h *MessageHandler) Disconnected(id string) error { + if h.locked && len(h.sessions.Admins()) == 0 { + h.locked = false + } + + return h.sessions.Destroy(id) +} + +func (h *MessageHandler) Message(id string, raw []byte) error { + header := message.Message{} + if err := json.Unmarshal(raw, &header); err != nil { + return err + } + + session, ok := h.sessions.Get(id) + if !ok { + errors.Errorf("unknown session id %s", id) + } + + switch header.Event { + // Signal Events + case event.SIGNAL_ANSWER: + payload := &message.SignalAnswer{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.signalAnswer(id, session, payload) + }), "%s failed", header.Event) + + // Control Events + case event.CONTROL_RELEASE: + return errors.Wrapf(h.controlRelease(id, session), "%s failed", header.Event) + case event.CONTROL_REQUEST: + return errors.Wrapf(h.controlRequest(id, session), "%s failed", header.Event) + case event.CONTROL_GIVE: + payload := &message.Control{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.controlGive(id, session, payload) + }), "%s failed", header.Event) + case event.CONTROL_CLIPBOARD: + payload := &message.Clipboard{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.controlClipboard(id, session, payload) + }), "%s failed", header.Event) + case event.CONTROL_KEYBOARD: + payload := &message.Keyboard{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.controlKeyboard(id, session, payload) + }), "%s failed", header.Event) + + + // Chat Events + case event.CHAT_MESSAGE: + payload := &message.ChatReceive{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.chat(id, session, payload) + }), "%s failed", header.Event) + case event.CHAT_EMOTE: + payload := &message.EmoteReceive{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.chatEmote(id, session, payload) + }), "%s failed", header.Event) + + // Screen Events + case event.SCREEN_RESOLUTION: + return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event) + case event.SCREEN_CONFIGURATIONS: + return errors.Wrapf(h.screenConfigurations(id, session), "%s failed", header.Event) + case event.SCREEN_SET: + payload := &message.ScreenResolution{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.screenSet(id, session, payload) + }), "%s failed", header.Event) + + // Boradcast Events + case event.BORADCAST_CREATE: + payload := &message.BroadcastCreate{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.boradcastCreate(session, payload) + }), "%s failed", header.Event) + case event.BORADCAST_DESTROY: + return errors.Wrapf(h.boradcastDestroy(session), "%s failed", header.Event) + + // Admin Events + case event.ADMIN_LOCK: + return errors.Wrapf(h.adminLock(id, session), "%s failed", header.Event) + case event.ADMIN_UNLOCK: + return errors.Wrapf(h.adminUnlock(id, session), "%s failed", header.Event) + case event.ADMIN_CONTROL: + return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event) + case event.ADMIN_RELEASE: + return errors.Wrapf(h.adminRelease(id, session), "%s failed", header.Event) + case event.ADMIN_GIVE: + payload := &message.Admin{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.adminGive(id, session, payload) + }), "%s failed", header.Event) + case event.ADMIN_BAN: + payload := &message.Admin{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.adminBan(id, session, payload) + }), "%s failed", header.Event) + case event.ADMIN_KICK: + payload := &message.Admin{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.adminKick(id, session, payload) + }), "%s failed", header.Event) + case event.ADMIN_MUTE: + payload := &message.Admin{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.adminMute(id, session, payload) + }), "%s failed", header.Event) + case event.ADMIN_UNMUTE: + payload := &message.Admin{} + return errors.Wrapf( + utils.Unmarshal(payload, raw, func() error { + return h.adminUnmute(id, session, payload) + }), "%s failed", header.Event) + default: + return errors.Errorf("unknown message event %s", header.Event) + } +} diff --git a/internal/websocket/screen.go b/internal/websocket/screen.go new file mode 100644 index 00000000..2547f630 --- /dev/null +++ b/internal/websocket/screen.go @@ -0,0 +1,66 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) screenSet(id string, session types.Session, payload *message.ScreenResolution) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if err := h.remote.ChangeResolution(payload.Width, payload.Height, payload.Rate); err != nil { + h.logger.Warn().Err(err).Msgf("unable to change screen size") + return err + } + + if err := h.sessions.Broadcast( + message.ScreenResolution{ + Event: event.SCREEN_RESOLUTION, + ID: id, + Width: payload.Width, + Height: payload.Height, + Rate: payload.Rate, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.SCREEN_RESOLUTION) + return err + } + + return nil +} + +func (h *MessageHandler) screenResolution(id string, session types.Session) error { + if size := h.remote.GetScreenSize(); size != nil { + if err := session.Send(message.ScreenResolution{ + Event: event.SCREEN_RESOLUTION, + Width: size.Width, + Height: size.Height, + Rate: int(size.Rate), + }); err != nil { + h.logger.Warn().Err(err).Msgf("sending event %s has failed", event.SCREEN_RESOLUTION) + return err + } + } + + return nil +} + +func (h *MessageHandler) screenConfigurations(id string, session types.Session) error { + if !session.Admin() { + h.logger.Debug().Msg("user not admin") + return nil + } + + if err := session.Send(message.ScreenConfigurations{ + Event: event.SCREEN_CONFIGURATIONS, + Configurations: h.remote.ScreenConfigurations(), + }); err != nil { + h.logger.Warn().Err(err).Msgf("sending event %s has failed", event.SCREEN_CONFIGURATIONS) + return err + } + + return nil +} diff --git a/internal/websocket/session.go b/internal/websocket/session.go new file mode 100644 index 00000000..9151aa23 --- /dev/null +++ b/internal/websocket/session.go @@ -0,0 +1,93 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) SessionCreated(id string, session types.Session) error { + // send sdp and id over to client + if err := h.signalProvide(id, session); err != nil { + return err + } + + if session.Admin() { + // send screen configurations if admin + if err := h.screenConfigurations(id, session); err != nil { + return err + } + + // send broadcast status if admin + if err := h.boradcastStatus(session); err != nil { + return err + } + } + + return nil +} + +func (h *MessageHandler) SessionConnected(id string, session types.Session) error { + // send list of members to session + if err := session.Send(message.MembersList{ + Event: event.MEMBER_LIST, + Memebers: h.sessions.Members(), + }); err != nil { + h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.MEMBER_LIST) + return err + } + + // send screen current resolution + if err := h.screenResolution(id, session); err != nil { + return err + } + + // tell session there is a host + host, ok := h.sessions.GetHost() + if ok { + if err := session.Send(message.Control{ + Event: event.CONTROL_LOCKED, + ID: host.ID(), + }); err != nil { + h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.CONTROL_LOCKED) + return err + } + } + + // let everyone know there is a new session + if err := h.sessions.Broadcast( + message.Member{ + Event: event.MEMBER_CONNECTED, + Member: session.Member(), + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE) + return err + } + + return nil +} + +func (h *MessageHandler) SessionDestroyed(id string) error { + // clear host if exists + if h.sessions.IsHost(id) { + h.sessions.ClearHost() + if err := h.sessions.Broadcast(message.Control{ + Event: event.CONTROL_RELEASE, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE) + } + } + + // let everyone know session disconnected + if err := h.sessions.Broadcast( + message.MemberDisconnected{ + Event: event.MEMBER_DISCONNECTED, + ID: id, + }, nil); err != nil { + h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.MEMBER_DISCONNECTED) + return err + } + + return nil +} diff --git a/internal/websocket/signal.go b/internal/websocket/signal.go new file mode 100644 index 00000000..e24db554 --- /dev/null +++ b/internal/websocket/signal.go @@ -0,0 +1,38 @@ +package websocket + +import ( + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" +) + +func (h *MessageHandler) signalProvide(id string, session types.Session) error { + sdp, lite, ice, err := h.webrtc.CreatePeer(id, session) + if err != nil { + return err + } + + if err := session.Send(message.SignalProvide{ + Event: event.SIGNAL_PROVIDE, + ID: id, + SDP: sdp, + Lite: lite, + ICE: ice, + }); err != nil { + return err + } + + return nil +} + +func (h *MessageHandler) signalAnswer(id string, session types.Session, payload *message.SignalAnswer) error { + if err := session.SetName(payload.DisplayName); err != nil { + return err + } + + if err := session.SignalAnswer(payload.SDP); err != nil { + return err + } + + return nil +} diff --git a/internal/websocket/socket.go b/internal/websocket/socket.go new file mode 100644 index 00000000..c9875bfd --- /dev/null +++ b/internal/websocket/socket.go @@ -0,0 +1,55 @@ +package websocket + +import ( + "encoding/json" + "strings" + "sync" + + "github.com/gorilla/websocket" +) + +type WebSocket struct { + id string + address string + ws *WebSocketHandler + connection *websocket.Conn + mu sync.Mutex +} + +func (socket *WebSocket) Address() string { + //remote := socket.connection.RemoteAddr() + address := strings.SplitN(socket.address, ":", -1) + if len(address[0]) < 1 { + return socket.address + } + return address[0] +} + +func (socket *WebSocket) Send(v interface{}) error { + socket.mu.Lock() + defer socket.mu.Unlock() + if socket.connection == nil { + return nil + } + + raw, err := json.Marshal(v) + if err != nil { + return err + } + + socket.ws.logger.Debug(). + Str("session", socket.id). + Str("address", socket.connection.RemoteAddr().String()). + Str("raw", string(raw)). + Msg("sending message to client") + + return socket.connection.WriteMessage(websocket.TextMessage, raw) +} + +func (socket *WebSocket) Destroy() error { + if socket.connection == nil { + return nil + } + + return socket.connection.Close() +} diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go new file mode 100644 index 00000000..77e9651d --- /dev/null +++ b/internal/websocket/websocket.go @@ -0,0 +1,268 @@ +package websocket + +import ( + "fmt" + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "n.eko.moe/neko/internal/types" + "n.eko.moe/neko/internal/types/config" + "n.eko.moe/neko/internal/types/event" + "n.eko.moe/neko/internal/types/message" + "n.eko.moe/neko/internal/utils" +) + +func New(sessions types.SessionManager, remote types.RemoteManager, broadcast types.BroadcastManager, webrtc types.WebRTCManager, conf *config.WebSocket) *WebSocketHandler { + logger := log.With().Str("module", "websocket").Logger() + + return &WebSocketHandler{ + logger: logger, + conf: conf, + sessions: sessions, + remote: remote, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + handler: &MessageHandler{ + logger: logger.With().Str("subsystem", "handler").Logger(), + remote: remote, + broadcast: broadcast, + sessions: sessions, + webrtc: webrtc, + banned: make(map[string]bool), + locked: false, + }, + } +} + +// Send pings to peer with this period. Must be less than pongWait. +const pingPeriod = 60 * time.Second + +type WebSocketHandler struct { + logger zerolog.Logger + upgrader websocket.Upgrader + sessions types.SessionManager + remote types.RemoteManager + conf *config.WebSocket + handler *MessageHandler + shutdown chan bool +} + +func (ws *WebSocketHandler) Start() error { + ws.sessions.OnCreated(func(id string, session types.Session) { + if err := ws.handler.SessionCreated(id, session); err != nil { + ws.logger.Warn().Str("id", id).Err(err).Msg("session created with and error") + } else { + ws.logger.Debug().Str("id", id).Msg("session created") + } + }) + + ws.sessions.OnConnected(func(id string, session types.Session) { + if err := ws.handler.SessionConnected(id, session); err != nil { + ws.logger.Warn().Str("id", id).Err(err).Msg("session connected with and error") + } else { + ws.logger.Debug().Str("id", id).Msg("session connected") + } + }) + + ws.sessions.OnDestroy(func(id string, session types.Session) { + if err := ws.handler.SessionDestroyed(id); err != nil { + ws.logger.Warn().Str("id", id).Err(err).Msg("session destroyed with and error") + } else { + ws.logger.Debug().Str("id", id).Msg("session destroyed") + } + }) + + go func() { + defer func() { + ws.logger.Info().Msg("shutdown") + }() + + current := ws.remote.ReadClipboard() + + for { + select { + case <-ws.shutdown: + return + default: + if ws.sessions.HasHost() { + text := ws.remote.ReadClipboard() + if text != current { + session, ok := ws.sessions.GetHost() + if ok { + session.Send(message.Clipboard{ + Event: event.CONTROL_CLIPBOARD, + Text: text, + }) + } + current = text + } + } + time.Sleep(100 * time.Millisecond) + } + } + }() + + return nil +} + +func (ws *WebSocketHandler) Shutdown() error { + ws.shutdown <- true + return nil +} + +func (ws *WebSocketHandler) Upgrade(w http.ResponseWriter, r *http.Request) error { + ws.logger.Debug().Msg("attempting to upgrade connection") + + connection, err := ws.upgrader.Upgrade(w, r, nil) + if err != nil { + ws.logger.Error().Err(err).Msg("failed to upgrade connection") + return err + } + + id, ip, admin, err := ws.authenticate(r) + if err != nil { + ws.logger.Warn().Err(err).Msg("authentication failed") + + if err = connection.WriteJSON(message.Disconnect{ + Event: event.SYSTEM_DISCONNECT, + Message: "invalid_password", + }); err != nil { + ws.logger.Error().Err(err).Msg("failed to send disconnect") + } + + if err = connection.Close(); err != nil { + return err + } + return nil + } + + socket := &WebSocket{ + id: id, + ws: ws, + address: ip, + connection: connection, + } + + ok, reason, err := ws.handler.Connected(id, socket) + if err != nil { + ws.logger.Error().Err(err).Msg("connection failed") + return err + } + + if !ok { + if err = connection.WriteJSON(message.Disconnect{ + Event: event.SYSTEM_DISCONNECT, + Message: reason, + }); err != nil { + ws.logger.Error().Err(err).Msg("failed to send disconnect") + } + + if err = connection.Close(); err != nil { + return err + } + + return nil + } + + ws.sessions.New(id, admin, socket) + + ws.logger. + Debug(). + Str("session", id). + Str("address", connection.RemoteAddr().String()). + Msg("new connection created") + + defer func() { + ws.logger. + Debug(). + Str("session", id). + Str("address", connection.RemoteAddr().String()). + Msg("session ended") + }() + + ws.handle(connection, id) + return nil +} + +func (ws *WebSocketHandler) authenticate(r *http.Request) (string, string, bool, error) { + ip := r.RemoteAddr + + if ws.conf.Proxy { + ip = utils.ReadUserIP(r) + } + + id, err := utils.NewUID(32) + if err != nil { + return "", ip, false, err + } + + passwords, ok := r.URL.Query()["password"] + if !ok || len(passwords[0]) < 1 { + return "", ip, false, fmt.Errorf("no password provided") + } + + if passwords[0] == ws.conf.AdminPassword { + return id, ip, true, nil + } + + if passwords[0] == ws.conf.Password { + return id, ip, false, nil + } + + return "", ip, false, fmt.Errorf("invalid password: %s", passwords[0]) +} + +func (ws *WebSocketHandler) handle(connection *websocket.Conn, id string) { + bytes := make(chan []byte) + cancel := make(chan struct{}) + ticker := time.NewTicker(pingPeriod) + + go func() { + defer func() { + ticker.Stop() + ws.logger.Debug().Str("address", connection.RemoteAddr().String()).Msg("handle socket ending") + ws.handler.Disconnected(id) + }() + + for { + _, raw, err := connection.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + ws.logger.Warn().Err(err).Msg("read message error") + } else { + ws.logger.Debug().Err(err).Msg("read message error") + } + close(cancel) + break + } + bytes <- raw + } + }() + + for { + select { + case raw := <-bytes: + ws.logger.Debug(). + Str("session", id). + Str("address", connection.RemoteAddr().String()). + Str("raw", string(raw)). + Msg("received message from client") + if err := ws.handler.Message(id, raw); err != nil { + ws.logger.Error().Err(err).Msg("message handler has failed") + } + case <-cancel: + return + case _ = <-ticker.C: + if err := connection.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/internal/xorg/xorg.c b/internal/xorg/xorg.c new file mode 100644 index 00000000..58421f5f --- /dev/null +++ b/internal/xorg/xorg.c @@ -0,0 +1,195 @@ +#include "xorg.h" + +static clipboard_c *CLIPBOARD = NULL; +static Display *DISPLAY = NULL; +static char *NAME = ":0.0"; +static int REGISTERED = 0; +static int DIRTY = 0; + +Display *getXDisplay(void) { + /* Close the display if displayName has changed */ + if (DIRTY) { + XDisplayClose(); + DIRTY = 0; + } + + if (DISPLAY == NULL) { + /* First try the user set displayName */ + DISPLAY = XOpenDisplay(NAME); + + /* Then try using environment variable DISPLAY */ + if (DISPLAY == NULL) { + DISPLAY = XOpenDisplay(NULL); + } + + if (DISPLAY == NULL) { + fputs("Could not open main display\n", stderr); + } else if (!REGISTERED) { + atexit(&XDisplayClose); + REGISTERED = 1; + } + } + + return DISPLAY; +} + +clipboard_c *getClipboard(void) { + if (CLIPBOARD == NULL) { + CLIPBOARD = clipboard_new(NULL); + } + return CLIPBOARD; +} + +void XDisplayClose(void) { + if (DISPLAY != NULL) { + XCloseDisplay(DISPLAY); + DISPLAY = NULL; + } +} + +void XDisplaySet(char *input) { + NAME = strdup(input); + DIRTY = 1; +} + +void XMove(int x, int y) { + Display *display = getXDisplay(); + XWarpPointer(display, None, DefaultRootWindow(display), 0, 0, 0, 0, x, y); + XSync(display, 0); +} + +void XScroll(int x, int y) { + int ydir = 4; /* Button 4 is up, 5 is down. */ + int xdir = 6; + + Display *display = getXDisplay(); + + if (y < 0) { + ydir = 5; + } + + if (x < 0) { + xdir = 7; + } + + int xi; + int yi; + + for (xi = 0; xi < abs(x); xi++) { + XTestFakeButtonEvent(display, xdir, 1, CurrentTime); + XTestFakeButtonEvent(display, xdir, 0, CurrentTime); + } + + for (yi = 0; yi < abs(y); yi++) { + XTestFakeButtonEvent(display, ydir, 1, CurrentTime); + XTestFakeButtonEvent(display, ydir, 0, CurrentTime); + } + + XSync(display, 0); +} + +void XButton(unsigned int button, int down) { + if (button != 0) { + Display *display = getXDisplay(); + XTestFakeButtonEvent(display, button, down, CurrentTime); + XSync(display, 0); + } +} + +void XKey(unsigned long key, int down) { + if (key != 0) { + Display *display = getXDisplay(); + KeyCode code = XKeysymToKeycode(display, key); + + // Map non-existing keysyms to new keycodes + if(code == 0) { + int min, max, numcodes; + XDisplayKeycodes(display, &min, &max); + XGetKeyboardMapping(display, min, max-min, &numcodes); + + code = (max-min+1)*numcodes; + KeySym keysym_list[numcodes]; + for(int i=0;i + #include + #include + #include + #include + #include + #include + #include /* For fputs() */ + #include /* For strdup() */ + + extern void goCreateScreenSize(int index, int width, int height, int mwidth, int mheight); + extern void goSetScreenRates(int index, int rate_index, short rate); + + /* Returns the main display, closed either on exit or when closeMainDisplay() + * is invoked. This removes a bit of the overhead of calling XOpenDisplay() & + * XCloseDisplay() everytime the main display needs to be used. + * + * Note that this is almost certainly not thread safe. */ + Display *getXDisplay(void); + clipboard_c *getClipboard(void); + + void XMove(int x, int y); + void XScroll(int x, int y); + void XButton(unsigned int button, int down); + void XKey(unsigned long key, int down); + + void XClipboardSet(char *src); + char *XClipboardGet(); + + void XGetScreenConfigurations(); + void XSetScreenConfiguration(int index, short rate); + int XGetScreenSize(); + short XGetScreenRate(); + + void XDisplayClose(void); + void XDisplaySet(char *input); + + void SetKeyboardLayout(char *layout); + void SetKeyboardModifiers(int num_lock, int caps_lock, int scroll_lock); +#endif + diff --git a/neko.code-workspace b/neko.code-workspace new file mode 100644 index 00000000..876a1499 --- /dev/null +++ b/neko.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/neko.go b/neko.go new file mode 100644 index 00000000..0d6026fd --- /dev/null +++ b/neko.go @@ -0,0 +1,184 @@ +package neko + +import ( + "fmt" + "os" + "os/signal" + "runtime" + + "n.eko.moe/neko/internal/broadcast" + "n.eko.moe/neko/internal/http" + "n.eko.moe/neko/internal/remote" + "n.eko.moe/neko/internal/session" + "n.eko.moe/neko/internal/types/config" + "n.eko.moe/neko/internal/webrtc" + "n.eko.moe/neko/internal/websocket" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +const Header = `&34 + _ __ __ + / | / /__ / /______ \ /\ + / |/ / _ \/ //_/ __ \ ) ( ') + / /| / __/ ,< / /_/ / ( / ) +/_/ |_/\___/_/|_|\____/ \(__)| +&1&37 nurdism/neko &33%s v%s&0 +` + +var ( + // + buildDate = "dev" + // + gitCommit = "dev" + // + gitBranch = "dev" + + // Major version when you make incompatible API changes, + major = "dev" + // Minor version when you add functionality in a backwards-compatible manner, and + minor = "dev" + // Patch version when you make backwards-compatible bug fixeneko. + patch = "dev" +) + +var Service *Neko + +func init() { + Service = &Neko{ + Version: &Version{ + Major: major, + Minor: minor, + Patch: patch, + GitCommit: gitCommit, + GitBranch: gitBranch, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + }, + Root: &config.Root{}, + Server: &config.Server{}, + Remote: &config.Remote{}, + Broadcast: &config.Broadcast{}, + WebRTC: &config.WebRTC{}, + WebSocket: &config.WebSocket{}, + } +} + +type Version struct { + Major string + Minor string + Patch string + GitCommit string + GitBranch string + BuildDate string + GoVersion string + Compiler string + Platform string +} + +func (i *Version) String() string { + return fmt.Sprintf("%s.%s.%s %s", i.Major, i.Minor, i.Patch, i.GitCommit) +} + +func (i *Version) Details() string { + return fmt.Sprintf( + "%s\n%s\n%s\n%s\n%s\n%s\n%s\n", + fmt.Sprintf("Version %s.%s.%s", i.Major, i.Minor, i.Patch), + fmt.Sprintf("GitCommit %s", i.GitCommit), + fmt.Sprintf("GitBranch %s", i.GitBranch), + fmt.Sprintf("BuildDate %s", i.BuildDate), + fmt.Sprintf("GoVersion %s", i.GoVersion), + fmt.Sprintf("Compiler %s", i.Compiler), + fmt.Sprintf("Platform %s", i.Platform), + ) +} + +type Neko struct { + Version *Version + Root *config.Root + Remote *config.Remote + Broadcast *config.Broadcast + Server *config.Server + WebRTC *config.WebRTC + WebSocket *config.WebSocket + + logger zerolog.Logger + server *http.Server + sessionManager *session.SessionManager + remoteManager *remote.RemoteManager + broadcastManager *broadcast.BroadcastManager + webRTCManager *webrtc.WebRTCManager + webSocketHandler *websocket.WebSocketHandler +} + +func (neko *Neko) Preflight() { + neko.logger = log.With().Str("service", "neko").Logger() +} + +func (neko *Neko) Start() { + broadcastManager := broadcast.New(neko.Remote, neko.Broadcast) + + remoteManager := remote.New(neko.Remote, broadcastManager) + remoteManager.Start() + + sessionManager := session.New(remoteManager) + + webRTCManager := webrtc.New(sessionManager, remoteManager, neko.WebRTC) + webRTCManager.Start() + + webSocketHandler := websocket.New(sessionManager, remoteManager, broadcastManager, webRTCManager, neko.WebSocket) + webSocketHandler.Start() + + server := http.New(neko.Server, webSocketHandler) + server.Start() + + neko.sessionManager = sessionManager + neko.remoteManager = remoteManager + neko.webRTCManager = webRTCManager + neko.webSocketHandler = webSocketHandler + neko.server = server +} + +func (neko *Neko) Shutdown() { + if err := neko.remoteManager.Shutdown(); err != nil { + neko.logger.Err(err).Msg("remote manager shutdown with an error") + } else { + neko.logger.Debug().Msg("remote manager shutdown") + } + + if err := neko.webRTCManager.Shutdown(); err != nil { + neko.logger.Err(err).Msg("webrtc manager shutdown with an error") + } else { + neko.logger.Debug().Msg("webrtc manager shutdown") + } + + if err := neko.webSocketHandler.Shutdown(); err != nil { + neko.logger.Err(err).Msg("websocket handler shutdown with an error") + } else { + neko.logger.Debug().Msg("websocket handler shutdown") + } + + if err := neko.server.Shutdown(); err != nil { + neko.logger.Err(err).Msg("server shutdown with an error") + } else { + neko.logger.Debug().Msg("server shutdown") + } +} + +func (neko *Neko) ServeCommand(cmd *cobra.Command, args []string) { + neko.logger.Info().Msg("starting neko server") + neko.Start() + neko.logger.Info().Msg("neko ready") + + quit := make(chan os.Signal) + signal.Notify(quit, os.Interrupt) + sig := <-quit + + neko.logger.Warn().Msgf("received %s, attempting graceful shutdown: \n", sig) + neko.Shutdown() + neko.logger.Info().Msg("shutdown complete") +}