mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-08 07:10:54 +00:00
Merge remote-tracking branch 'remotes/riking/socketdev'
This commit is contained in:
commit
886f45b990
42 changed files with 6135 additions and 1959 deletions
1127
socketserver/SocketServerDesign.svg
Normal file
1127
socketserver/SocketServerDesign.svg
Normal file
File diff suppressed because it is too large
Load diff
After Width: | Height: | Size: 48 KiB |
3
socketserver/cmd/ffzsocketserver/.gitignore
vendored
Normal file
3
socketserver/cmd/ffzsocketserver/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
ffzsocketserver
|
||||
uniques/
|
136
socketserver/cmd/ffzsocketserver/console.go
Normal file
136
socketserver/cmd/ffzsocketserver/console.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"../../server"
|
||||
"github.com/abiosoft/ishell"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func commandLineConsole() {
|
||||
|
||||
shell := ishell.NewShell()
|
||||
|
||||
shell.Register("help", func(args ...string) (string, error) {
|
||||
shell.PrintCommands()
|
||||
return "", nil
|
||||
})
|
||||
|
||||
shell.Register("clientcount", func(args ...string) (string, error) {
|
||||
server.GlobalSubscriptionLock.RLock()
|
||||
count := len(server.GlobalSubscriptionInfo)
|
||||
server.GlobalSubscriptionLock.RUnlock()
|
||||
return fmt.Sprintln(count, "clients connected"), nil
|
||||
})
|
||||
|
||||
shell.Register("globalnotice", func(args ...string) (string, error) {
|
||||
msg := server.ClientMessage{
|
||||
MessageID: -1,
|
||||
Command: "message",
|
||||
Arguments: args[0],
|
||||
}
|
||||
server.PublishToAll(msg)
|
||||
return "Message sent.", nil
|
||||
})
|
||||
|
||||
shell.Register("publish", func(args ...string) (string, error) {
|
||||
if len(args) < 4 {
|
||||
return "Usage: publish [room.sirstendec | _ALL] -1 reload_ff 23", nil
|
||||
}
|
||||
|
||||
target := args[0]
|
||||
line := strings.Join(args[1:], " ")
|
||||
msg := server.ClientMessage{}
|
||||
err := server.UnmarshalClientMessage([]byte(line), websocket.TextMessage, &msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var count int
|
||||
if target == "_ALL" {
|
||||
count = server.PublishToAll(msg)
|
||||
} else {
|
||||
count = server.PublishToChannel(target, msg)
|
||||
}
|
||||
return fmt.Sprintf("Published to %d clients", count), nil
|
||||
})
|
||||
|
||||
shell.Register("memstatsbysize", func(args ...string) (string, error) {
|
||||
runtime.GC()
|
||||
|
||||
m := runtime.MemStats{}
|
||||
runtime.ReadMemStats(&m)
|
||||
for _, val := range m.BySize {
|
||||
if val.Mallocs == 0 {
|
||||
continue
|
||||
}
|
||||
shell.Print(fmt.Sprintf("%5d: %6d outstanding (%d total)\n", val.Size, val.Mallocs-val.Frees, val.Mallocs))
|
||||
}
|
||||
shell.Println(m.NumGC, "collections occurred")
|
||||
return "", nil
|
||||
})
|
||||
|
||||
shell.Register("authorizeeveryone", func(args ...string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
if server.Configuration.SendAuthToNewClients {
|
||||
return "All clients are recieving auth challenges upon claiming a name.", nil
|
||||
}
|
||||
return "All clients are not recieving auth challenges upon claiming a name.", nil
|
||||
} else if args[0] == "on" {
|
||||
server.Configuration.SendAuthToNewClients = true
|
||||
return "All new clients will recieve auth challenges upon claiming a name.", nil
|
||||
} else if args[0] == "off" {
|
||||
server.Configuration.SendAuthToNewClients = false
|
||||
return "All new clients will not recieve auth challenges upon claiming a name.", nil
|
||||
}
|
||||
return "Usage: authorizeeveryone [ on | off ]", nil
|
||||
})
|
||||
|
||||
shell.Register("kickclients", func(args ...string) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "Please enter either a count or a fraction of clients to kick.", nil
|
||||
}
|
||||
input, err := strconv.ParseFloat(args[0], 64)
|
||||
if err != nil {
|
||||
return "Argument must be a number", err
|
||||
}
|
||||
var count int
|
||||
if input >= 1 {
|
||||
count = int(input)
|
||||
} else {
|
||||
server.GlobalSubscriptionLock.RLock()
|
||||
count = int(float64(len(server.GlobalSubscriptionInfo)) * input)
|
||||
server.GlobalSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
msg := server.ClientMessage{Arguments: &server.CloseRebalance}
|
||||
server.GlobalSubscriptionLock.RLock()
|
||||
defer server.GlobalSubscriptionLock.RUnlock()
|
||||
|
||||
kickCount := 0
|
||||
for i, cl := range server.GlobalSubscriptionInfo {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case cl.MessageChannel <- msg:
|
||||
case <-cl.MsgChannelIsDone:
|
||||
}
|
||||
kickCount++
|
||||
}
|
||||
return fmt.Sprintf("Kicked %d clients", kickCount), nil
|
||||
})
|
||||
|
||||
shell.Register("panic", func(args ...string) (string, error) {
|
||||
go func() {
|
||||
panic("requested panic")
|
||||
}()
|
||||
return "", nil
|
||||
})
|
||||
|
||||
shell.Start()
|
||||
}
|
13
socketserver/cmd/ffzsocketserver/index.html
Normal file
13
socketserver/cmd/ffzsocketserver/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<title>CatBag</title>
|
||||
<link rel="stylesheet" href="//cdn.frankerfacez.com/script/catbag.css">
|
||||
<div id="container">
|
||||
<div id="zf0"></div><div id="zf1"></div><div id="zf2"></div>
|
||||
<div id="zf3"></div><div id="zf4"></div><div id="zf5"></div>
|
||||
<div id="zf6"></div><div id="zf7"></div><div id="zf8"></div>
|
||||
<div id="zf9"></div><div id="catbag"></div>
|
||||
<div id="bottom">
|
||||
A <a href="http://www.frankerfacez.com/">FrankerFaceZ</a> Service
|
||||
— CatBag by <a href="http://www.twitch.tv/wolsk">Wolsk</a>
|
||||
</div>
|
||||
</div>
|
|
@ -1,7 +1,6 @@
|
|||
package main // import "bitbucket.org/stendec/frankerfacez/socketserver/cmd/socketserver"
|
||||
package main // import "bitbucket.org/stendec/frankerfacez/socketserver/cmd/ffzsocketserver"
|
||||
|
||||
import (
|
||||
"../../internal/server"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
@ -9,16 +8,23 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"../../server"
|
||||
)
|
||||
|
||||
var configFilename *string = flag.String("config", "config.json", "Configuration file, including the keypairs for the NaCl crypto library, for communicating with the backend.")
|
||||
var generateKeys *bool = flag.Bool("genkeys", false, "Generate NaCl keys instead of serving requests.\nArguments: [int serverId] [base64 backendPublic]\nThe backend public key can either be specified in base64 on the command line, or put in the json file later.")
|
||||
import _ "net/http/pprof"
|
||||
|
||||
var configFilename = flag.String("config", "config.json", "Configuration file, including the keypairs for the NaCl crypto library, for communicating with the backend.")
|
||||
var flagGenerateKeys = flag.Bool("genkeys", false, "Generate NaCl keys instead of serving requests.\nArguments: [int serverId] [base64 backendPublic]\nThe backend public key can either be specified in base64 on the command line, or put in the json file later.")
|
||||
|
||||
var BuildTime string = "build not stamped"
|
||||
var BuildHash string = "build not stamped"
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *generateKeys {
|
||||
GenerateKeys(*configFilename)
|
||||
if *flagGenerateKeys {
|
||||
generateKeys(*configFilename)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -40,24 +46,30 @@ func main() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: conf.ListenAddr,
|
||||
}
|
||||
// logFile, err := os.OpenFile("output.log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
// if err != nil {
|
||||
// log.Fatal("Could not create logfile: ", err)
|
||||
// }
|
||||
|
||||
server.SetupServerAndHandle(conf, httpServer.TLSConfig, nil)
|
||||
server.SetupServerAndHandle(conf, http.DefaultServeMux)
|
||||
server.SetBuildStamp(BuildTime, BuildHash)
|
||||
|
||||
go commandLineConsole()
|
||||
|
||||
if conf.UseSSL {
|
||||
err = httpServer.ListenAndServeTLS(conf.SSLCertificateFile, conf.SSLKeyFile)
|
||||
} else {
|
||||
err = httpServer.ListenAndServe()
|
||||
go func() {
|
||||
if err := http.ListenAndServeTLS(conf.SSLListenAddr, conf.SSLCertificateFile, conf.SSLKeyFile, http.DefaultServeMux); err != nil {
|
||||
log.Fatal("ListenAndServeTLS: ", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err = http.ListenAndServe(conf.ListenAddr, http.DefaultServeMux); err != nil {
|
||||
log.Fatal("ListenAndServe: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateKeys(outputFile string) {
|
||||
func generateKeys(outputFile string) {
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Println("Specify a numeric server ID after -genkeys")
|
||||
os.Exit(2)
|
||||
|
|
1
socketserver/cmd/mergecounts/.gitignore
vendored
Normal file
1
socketserver/cmd/mergecounts/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
mergecounts
|
102
socketserver/cmd/mergecounts/mergecounts.go
Normal file
102
socketserver/cmd/mergecounts/mergecounts.go
Normal file
|
@ -0,0 +1,102 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"../../server"
|
||||
"github.com/clarkduvall/hyperloglog"
|
||||
)
|
||||
|
||||
var SERVERS = []string{
|
||||
"https://catbag.frankerfacez.com",
|
||||
"https://andknuckles.frankerfacez.com",
|
||||
"https://tuturu.frankerfacez.com",
|
||||
}
|
||||
|
||||
const folderPrefix = "/hll/"
|
||||
|
||||
const HELP = `
|
||||
Usage: mergecounts [filename]
|
||||
|
||||
Downloads the file /hll/filename from the 3 FFZ socket servers, merges the contents, and prints the total cardinality.
|
||||
|
||||
Filename should be in one of the following formats:
|
||||
|
||||
daily-25-12-2015.gob
|
||||
weekly-51-2015.gob
|
||||
monthly-12-2015.gob
|
||||
`
|
||||
|
||||
var forceWrite = flag.Bool("f", false, "force servers to write out their current")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Print(HELP)
|
||||
os.Exit(2)
|
||||
return
|
||||
}
|
||||
|
||||
filename := flag.Arg(0)
|
||||
hll, err := DownloadAll(filename)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(hll.Count())
|
||||
}
|
||||
|
||||
func ForceWrite() {
|
||||
for _, server := range SERVERS {
|
||||
resp, err := http.Get(fmt.Sprintf("%s/hll_force_write", server))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func DownloadAll(filename string) (*hyperloglog.HyperLogLogPlus, error) {
|
||||
result, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
|
||||
for _, server := range SERVERS {
|
||||
if *forceWrite {
|
||||
resp, err := http.Get(fmt.Sprintf("%s/hll_force_write", server))
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
singleHLL, err := DownloadHLL(fmt.Sprintf("%s%s%s", server, folderPrefix, filename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Merge(singleHLL)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DownloadHLL(url string) (*hyperloglog.HyperLogLogPlus, error) {
|
||||
result, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
dec := gob.NewDecoder(resp.Body)
|
||||
err = dec.Decode(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println(url, result.Count())
|
||||
|
||||
return result, nil
|
||||
}
|
3
socketserver/cmd/statsweb/.gitignore
vendored
Normal file
3
socketserver/cmd/statsweb/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
database.sqlite
|
||||
gobcache/
|
||||
statsweb
|
74
socketserver/cmd/statsweb/config.go
Normal file
74
socketserver/cmd/statsweb/config.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ConfigFile struct {
|
||||
ListenAddr string
|
||||
DatabaseLocation string
|
||||
GobFilesLocation string
|
||||
}
|
||||
|
||||
func makeConfig() {
|
||||
config.ListenAddr = "localhost:3000"
|
||||
home, ok := os.LookupEnv("HOME")
|
||||
if ok {
|
||||
config.DatabaseLocation = fmt.Sprintf("%s/.ffzstatsweb/database.sqlite", home)
|
||||
config.GobFilesLocation = fmt.Sprintf("%s/.ffzstatsweb/gobcache", home)
|
||||
os.MkdirAll(config.GobFilesLocation, 0755)
|
||||
} else {
|
||||
config.DatabaseLocation = "./database.sqlite"
|
||||
config.GobFilesLocation = "./gobcache"
|
||||
os.MkdirAll(config.GobFilesLocation, 0755)
|
||||
}
|
||||
file, err := os.Create(*configLocation)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: could not create config file: %v\n", err)
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(file)
|
||||
err = enc.Encode(config)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: could not write config file: %v\n", err)
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: could not write config file: %v\n", err)
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
file, err := os.Open(*configLocation)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("You must create a config file with -genconf")
|
||||
} else {
|
||||
fmt.Printf("Error: could not load config file: %v", err)
|
||||
}
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
dec := json.NewDecoder(file)
|
||||
err = dec.Decode(&config)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: could not load config file: %v\n", err)
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: could not load config file: %v\n", err)
|
||||
os.Exit(ExitCodeBadConfig)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
1
socketserver/cmd/statsweb/config.json
Normal file
1
socketserver/cmd/statsweb/config.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"ListenAddr":"localhost:3000","DatabaseLocation":"./database.sqlite","GobFilesLocation":"./gobcache"}
|
62
socketserver/cmd/statsweb/html.go
Normal file
62
socketserver/cmd/statsweb/html.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bitbucket.org/stendec/frankerfacez/socketserver/server"
|
||||
)
|
||||
|
||||
type CalendarData struct {
|
||||
Weeks []CalWeekData
|
||||
}
|
||||
type CalWeekData struct {
|
||||
Days []CalDayData
|
||||
}
|
||||
type CalDayData struct {
|
||||
NoData bool
|
||||
Date int
|
||||
UniqUsers int
|
||||
}
|
||||
|
||||
type CalendarMonthInfo struct {
|
||||
Year int
|
||||
Month time.Month
|
||||
// Ranges from -5 to +1.
|
||||
// A value of +1 means the 1st of the month is a Sunday.
|
||||
// A value of 0 means the 1st of the month is a Monday.
|
||||
// A value of -5 means the 1st of the month is a Saturday.
|
||||
FirstSundayOffset int
|
||||
// True if the calendar for this month needs six sundays.
|
||||
NeedSixSundays bool
|
||||
}
|
||||
|
||||
func GetMonthInfo(at time.Time) CalendarMonthInfo {
|
||||
year, month, _ := at.Date()
|
||||
monthStartWeekday := time.Date(year, month, 1, 0, 0, 0, 0, server.CounterLocation).Weekday()
|
||||
// 1 (start of month) - weekday of start of month = day offset of start of week at start of mont
|
||||
monthWeekStartDay := 1 - int(monthStartWeekday)
|
||||
// first day on calendar + 6 weeks < end of month?
|
||||
sixthSundayDay := monthWeekStartDay + 5*7
|
||||
sixthSundayDate := time.Date(year, month, sixthSundayDay, 0, 0, 0, 0, server.CounterLocation)
|
||||
var needSixSundays bool = false
|
||||
if sixthSundayDate.Month() == month {
|
||||
needSixSundays = true
|
||||
}
|
||||
|
||||
return CalendarMonthInfo{
|
||||
Year: year,
|
||||
Month: month,
|
||||
FirstSundayOffset: monthWeekStartDay,
|
||||
NeedSixSundays: needSixSundays,
|
||||
}
|
||||
}
|
||||
|
||||
func renderCalendar(w http.ResponseWriter, at time.Time) {
|
||||
layout, err := template.ParseFiles("./webroot/layout.template.html", "./webroot/cal_entry.hbs", "./webroot/calendar.hbs")
|
||||
data := CalendarData{}
|
||||
data.Weeks = make([]CalWeekData, 6)
|
||||
_ = layout
|
||||
_ = err
|
||||
}
|
342
socketserver/cmd/statsweb/servers.go
Normal file
342
socketserver/cmd/statsweb/servers.go
Normal file
|
@ -0,0 +1,342 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bitbucket.org/stendec/frankerfacez/socketserver/server"
|
||||
"github.com/clarkduvall/hyperloglog"
|
||||
"github.com/hashicorp/golang-lru"
|
||||
)
|
||||
|
||||
type serverFilter struct {
|
||||
// Mode is false for blacklist, true for whitelist
|
||||
Mode bool
|
||||
Special []string
|
||||
}
|
||||
|
||||
const serverFilterModeBlacklist = false
|
||||
const serverFilterModeWhitelist = true
|
||||
|
||||
func (sf *serverFilter) IsServerAllowed(server *serverInfo) bool {
|
||||
name := server.subdomain
|
||||
for _, v := range sf.Special {
|
||||
if name == v {
|
||||
return sf.Mode
|
||||
}
|
||||
}
|
||||
return !sf.Mode
|
||||
}
|
||||
|
||||
func (sf *serverFilter) Remove(server string) {
|
||||
if sf.Mode == serverFilterModeWhitelist {
|
||||
var idx int = -1
|
||||
for i, v := range sf.Special {
|
||||
if server == v {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx != -1 {
|
||||
var lenMinusOne = len(sf.Special) - 1
|
||||
sf.Special[idx] = sf.Special[lenMinusOne]
|
||||
sf.Special = sf.Special[:lenMinusOne]
|
||||
}
|
||||
} else {
|
||||
for _, v := range sf.Special {
|
||||
if server == v {
|
||||
return
|
||||
}
|
||||
}
|
||||
sf.Special = append(sf.Special, server)
|
||||
}
|
||||
}
|
||||
|
||||
func (sf *serverFilter) Add(server string) {
|
||||
if sf.Mode == serverFilterModeBlacklist {
|
||||
var idx int = -1
|
||||
for i, v := range sf.Special {
|
||||
if server == v {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx != -1 {
|
||||
var lenMinusOne = len(sf.Special) - 1
|
||||
sf.Special[idx] = sf.Special[lenMinusOne]
|
||||
sf.Special = sf.Special[:lenMinusOne]
|
||||
}
|
||||
} else {
|
||||
for _, v := range sf.Special {
|
||||
if server == v {
|
||||
return
|
||||
}
|
||||
}
|
||||
sf.Special = append(sf.Special, server)
|
||||
}
|
||||
}
|
||||
|
||||
var serverFilterAll serverFilter = serverFilter{Mode: serverFilterModeBlacklist}
|
||||
var serverFilterNone serverFilter = serverFilter{Mode: serverFilterModeWhitelist}
|
||||
|
||||
func cannotCacheHLL(at time.Time) bool {
|
||||
now := time.Now()
|
||||
now.Add(-72 * time.Hour)
|
||||
return now.Before(at)
|
||||
}
|
||||
|
||||
var ServerNames = []string{
|
||||
"catbag",
|
||||
"andknuckles",
|
||||
"tuturu",
|
||||
}
|
||||
|
||||
var httpClient http.Client
|
||||
|
||||
const serverNameSuffix = ".frankerfacez.com"
|
||||
|
||||
const failedStateThreshold = 4
|
||||
|
||||
var ErrServerInFailedState = errors.New("server has been down recently and not recovered")
|
||||
var ErrServerHasNoData = errors.New("no data for specified date")
|
||||
|
||||
type errServerNot200 struct {
|
||||
StatusCode int
|
||||
StatusText string
|
||||
}
|
||||
|
||||
func (e *errServerNot200) Error() string {
|
||||
return fmt.Sprintf("The server responded with %d %s", e.StatusCode, e.StatusText)
|
||||
}
|
||||
func Not200Error(resp *http.Response) *errServerNot200 {
|
||||
return &errServerNot200{
|
||||
StatusCode: resp.StatusCode,
|
||||
StatusText: resp.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func getHLLCacheKey(at time.Time) string {
|
||||
year, month, day := at.Date()
|
||||
return fmt.Sprintf("%d-%d-%d", year, month, day)
|
||||
}
|
||||
|
||||
type serverInfo struct {
|
||||
subdomain string
|
||||
|
||||
memcache *lru.TwoQueueCache
|
||||
|
||||
FailedState bool
|
||||
FailureErr error
|
||||
failureCount int
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (si *serverInfo) Setup(subdomain string) {
|
||||
si.subdomain = subdomain
|
||||
tq, err := lru.New2Q(60)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
si.memcache = tq
|
||||
}
|
||||
|
||||
// GetHLL gets the HLL from
|
||||
func (si *serverInfo) GetHLL(at time.Time) (*hyperloglog.HyperLogLogPlus, error) {
|
||||
if cannotCacheHLL(at) {
|
||||
fmt.Println(at)
|
||||
err := si.ForceWrite()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := si.DownloadHLL(at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("downloaded uncached hll %s:%s\n", si.subdomain, getHLLCacheKey(at))
|
||||
defer si.DeleteHLL(at)
|
||||
return loadHLLFromStream(reader)
|
||||
}
|
||||
|
||||
hll, ok := si.PeekHLL(at)
|
||||
if ok {
|
||||
fmt.Printf("got cached hll %s:%s\n", si.subdomain, getHLLCacheKey(at))
|
||||
return hll, nil
|
||||
}
|
||||
|
||||
reader, err := si.OpenHLL(at)
|
||||
if err != nil {
|
||||
// continue to download
|
||||
} else {
|
||||
//fmt.Printf("opened hll %s:%s\n", si.subdomain, getHLLCacheKey(at))
|
||||
return loadHLLFromStream(reader)
|
||||
}
|
||||
|
||||
reader, err = si.DownloadHLL(at)
|
||||
if err != nil {
|
||||
if err == ErrServerHasNoData {
|
||||
return hyperloglog.NewPlus(server.CounterPrecision)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("downloaded hll %s:%s\n", si.subdomain, getHLLCacheKey(at))
|
||||
return loadHLLFromStream(reader)
|
||||
}
|
||||
|
||||
func loadHLLFromStream(reader io.ReadCloser) (*hyperloglog.HyperLogLogPlus, error) {
|
||||
defer reader.Close()
|
||||
hll, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
dec := gob.NewDecoder(reader)
|
||||
err := dec.Decode(hll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hll, nil
|
||||
}
|
||||
|
||||
// PeekHLL tries to grab a HLL from the memcache without downloading it or hitting the disk.
|
||||
func (si *serverInfo) PeekHLL(at time.Time) (*hyperloglog.HyperLogLogPlus, bool) {
|
||||
if cannotCacheHLL(at) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
key := getHLLCacheKey(at)
|
||||
hll, ok := si.memcache.Get(key)
|
||||
if ok {
|
||||
return hll.(*hyperloglog.HyperLogLogPlus), true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (si *serverInfo) DeleteHLL(at time.Time) {
|
||||
year, month, day := at.Date()
|
||||
filename := fmt.Sprintf("%s/%s/%d-%d-%d.gob", config.GobFilesLocation, si.subdomain, year, month, day)
|
||||
err := os.Remove(filename)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (si *serverInfo) OpenHLL(at time.Time) (io.ReadCloser, error) {
|
||||
year, month, day := at.Date()
|
||||
filename := fmt.Sprintf("%s/%s/%d-%d-%d.gob", config.GobFilesLocation, si.subdomain, year, month, day)
|
||||
|
||||
file, err := os.Open(filename)
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
// file is nil
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (si *serverInfo) DownloadHLL(at time.Time) (io.ReadCloser, error) {
|
||||
if si.FailedState {
|
||||
return nil, ErrServerInFailedState
|
||||
}
|
||||
si.lock.Lock()
|
||||
defer si.lock.Unlock()
|
||||
|
||||
year, month, day := at.Date()
|
||||
url := fmt.Sprintf("https://%s/hll/daily-%d-%d-%d.gob", si.Domain(), day, month, year)
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
si.ServerFailed(err)
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, ErrServerHasNoData
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
err = Not200Error(resp)
|
||||
si.ServerFailed(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s/%s/%d-%d-%d.gob", config.GobFilesLocation, si.subdomain, year, month, day)
|
||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0644)
|
||||
if os.IsNotExist(err) {
|
||||
os.MkdirAll(fmt.Sprintf("%s/%s", config.GobFilesLocation, si.subdomain), 0755)
|
||||
file, err = os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0644)
|
||||
}
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("downloadhll: error opening file for writing: %v", err)
|
||||
}
|
||||
|
||||
return &teeReadCloser{r: resp.Body, w: file}, nil
|
||||
}
|
||||
|
||||
func (si *serverInfo) ForceWrite() error {
|
||||
if si.FailedState {
|
||||
return ErrServerInFailedState
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/hll_force_write", si.Domain())
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
si.ServerFailed(err)
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
err = Not200Error(resp)
|
||||
si.ServerFailed(err)
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (si *serverInfo) Domain() string {
|
||||
return fmt.Sprintf("%s%s", si.subdomain, serverNameSuffix)
|
||||
}
|
||||
|
||||
func (si *serverInfo) ServerFailed(err error) {
|
||||
si.lock.Lock()
|
||||
defer si.lock.Unlock()
|
||||
si.failureCount++
|
||||
if si.failureCount > failedStateThreshold {
|
||||
fmt.Printf("Server %s entering failed state\n", si.subdomain)
|
||||
si.FailedState = true
|
||||
si.FailureErr = err
|
||||
go recoveryCheck(si)
|
||||
}
|
||||
}
|
||||
|
||||
func recoveryCheck(si *serverInfo) {
|
||||
// TODO check for server recovery
|
||||
}
|
||||
|
||||
type teeReadCloser struct {
|
||||
r io.ReadCloser
|
||||
w io.WriteCloser
|
||||
}
|
||||
|
||||
func (t *teeReadCloser) Read(p []byte) (n int, err error) {
|
||||
n, err = t.r.Read(p)
|
||||
if n > 0 {
|
||||
if n, err := t.w.Write(p[:n]); err != nil {
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (t *teeReadCloser) Close() error {
|
||||
err1 := t.r.Close()
|
||||
err2 := t.w.Close()
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
322
socketserver/cmd/statsweb/statsweb.go
Normal file
322
socketserver/cmd/statsweb/statsweb.go
Normal file
|
@ -0,0 +1,322 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bitbucket.org/stendec/frankerfacez/socketserver/server"
|
||||
"github.com/clarkduvall/hyperloglog"
|
||||
)
|
||||
|
||||
var _ = os.Exit
|
||||
|
||||
var configLocation = flag.String("config", "./config.json", "Location of the configuration file. Defaults to ./config.json")
|
||||
var genConfig = flag.Bool("genconf", false, "Generate a new configuration file.")
|
||||
|
||||
var config ConfigFile
|
||||
|
||||
const ExitCodeBadConfig = 2
|
||||
|
||||
var allServers []*serverInfo
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *genConfig {
|
||||
makeConfig()
|
||||
return
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
|
||||
allServers = make([]*serverInfo, len(ServerNames))
|
||||
for i, v := range ServerNames {
|
||||
allServers[i] = &serverInfo{}
|
||||
allServers[i].Setup(v)
|
||||
}
|
||||
|
||||
//printEveryDay()
|
||||
//os.Exit(0)
|
||||
http.HandleFunc("/api/get", ServeAPIGet)
|
||||
http.ListenAndServe(config.ListenAddr, http.DefaultServeMux)
|
||||
}
|
||||
|
||||
func printEveryDay() {
|
||||
year := 2015
|
||||
month := 12
|
||||
day := 23
|
||||
filter := serverFilterAll
|
||||
var filter1, filter2, filter3 serverFilter
|
||||
filter1.Mode = serverFilterModeWhitelist
|
||||
filter2.Mode = serverFilterModeWhitelist
|
||||
filter3.Mode = serverFilterModeWhitelist
|
||||
filter1.Add(allServers[0].subdomain)
|
||||
filter2.Add(allServers[1].subdomain)
|
||||
filter3.Add(allServers[2].subdomain)
|
||||
stopTime := time.Now()
|
||||
var at time.Time
|
||||
const timeFmt = "2006-01-02"
|
||||
for ; stopTime.After(at); day++ {
|
||||
at = time.Date(year, time.Month(month), day, 0, 0, 0, 0, server.CounterLocation)
|
||||
hll, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
hll1, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
hll2, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
hll3, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
addSingleDate(at, filter, hll)
|
||||
addSingleDate(at, filter1, hll1)
|
||||
addSingleDate(at, filter2, hll2)
|
||||
addSingleDate(at, filter3, hll3)
|
||||
fmt.Printf("%s\t%d\t%d\t%d\t%d\n", at.Format(timeFmt), hll.Count(), hll1.Count(), hll2.Count(), hll3.Count())
|
||||
}
|
||||
}
|
||||
|
||||
const RequestURIName = "q"
|
||||
const separatorRange = "~"
|
||||
const separatorAdd = " "
|
||||
const separatorServer = "@"
|
||||
const jsonErrMalformedRequest = `{"status":"error","error":"malformed request uri"}`
|
||||
const jsonErrBlankRequest = `{"status":"error","error":"no queries given"}`
|
||||
const statusError = "error"
|
||||
const statusPartial = "partial"
|
||||
const statusOk = "ok"
|
||||
|
||||
type apiResponse struct {
|
||||
Status string `json:"status"`
|
||||
Responses []requestResponse `json:"resp"`
|
||||
}
|
||||
|
||||
type requestResponse struct {
|
||||
Status string `json:"status"`
|
||||
Request string `json:"req"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Count uint64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func ServeAPIGet(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
u, err := url.ParseRequestURI(r.RequestURI)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
fmt.Fprint(w, jsonErrMalformedRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
reqCount := len(query[RequestURIName])
|
||||
if reqCount == 0 {
|
||||
w.WriteHeader(400)
|
||||
fmt.Fprint(w, jsonErrBlankRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := apiResponse{Status: statusOk}
|
||||
resp.Responses = make([]requestResponse, reqCount)
|
||||
for i, v := range query[RequestURIName] {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
resp.Responses[i] = ProcessSingleGetRequest(v)
|
||||
}
|
||||
for _, v := range resp.Responses {
|
||||
if v.Status != statusOk {
|
||||
resp.Status = statusPartial
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(200)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.Encode(resp)
|
||||
}
|
||||
|
||||
var errRangeFormatIncorrect = errors.New("incorrect range format, must be yyyy-mm-dd~yyyy-mm-dd")
|
||||
|
||||
// ProcessSingleGetRequest takes a request string and pulls the unique user data for the given dates and filters.
|
||||
//
|
||||
// The request string is in the following format:
|
||||
//
|
||||
// Request = AddDateRanges [ "@" ServerFilter ] .
|
||||
// ServerFilter = [ "!" ] ServerName { " " ServerName } .
|
||||
// ServerName = { "a" … "z" } .
|
||||
// AddDateRanges = DateMaybeRange { " " DateMaybeRange } .
|
||||
// DateMaybeRange = DateRange | Date .
|
||||
// DateRange = Date "~" Date .
|
||||
// Date = Year "-" Month "-" Day .
|
||||
// Year = number number number number .
|
||||
// Month = number number .
|
||||
// Day = number number .
|
||||
// number = "0" … "9" .
|
||||
//
|
||||
// Example of a well-formed request:
|
||||
//
|
||||
// 2016-01-04~2016-01-08 2016-01-11~2016-01-15@andknuckles tuturu
|
||||
//
|
||||
// Remember that spaces are urlencoded as "+", so the HTTP request to send to retrieve that data would be this:
|
||||
//
|
||||
// /api/get?q=2016-01-04~2016-01-08+2016-01-11~2016-01-15%40andknuckles+tuturu
|
||||
//
|
||||
// If a ServerFilter is specified, only users connecting to the specified servers will be included in the count.
|
||||
//
|
||||
// It does not matter if a date is specified multiple times, due to the data format used.
|
||||
func ProcessSingleGetRequest(req string) (result requestResponse) {
|
||||
fmt.Println("processing request:", req)
|
||||
hll, _ := hyperloglog.NewPlus(server.CounterPrecision)
|
||||
|
||||
result.Request = req
|
||||
result.Status = statusOk
|
||||
filter := serverFilterAll
|
||||
|
||||
collectError := func(err error) bool {
|
||||
if err == ErrServerInFailedState {
|
||||
result.Status = statusPartial
|
||||
return false
|
||||
} else if err != nil {
|
||||
result.Status = statusError
|
||||
result.Error = err.Error()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
serverSplit := strings.Split(req, separatorServer)
|
||||
if len(serverSplit) == 2 {
|
||||
filter = serverFilterNone
|
||||
serversOnly := strings.Split(serverSplit[1], separatorAdd)
|
||||
for _, v := range serversOnly {
|
||||
filter.Add(v)
|
||||
}
|
||||
}
|
||||
|
||||
addSplit := strings.Split(serverSplit[0], separatorAdd)
|
||||
|
||||
outerLoop:
|
||||
for _, split1 := range addSplit {
|
||||
if len(split1) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rangeSplit := strings.Split(split1, separatorRange)
|
||||
if len(rangeSplit) == 1 {
|
||||
at, err := parseDateFromRequest(rangeSplit[0])
|
||||
if collectError(err) {
|
||||
break outerLoop
|
||||
}
|
||||
|
||||
err = addSingleDate(at, filter, hll)
|
||||
if collectError(err) {
|
||||
break outerLoop
|
||||
}
|
||||
} else if len(rangeSplit) == 2 {
|
||||
from, err := parseDateFromRequest(rangeSplit[0])
|
||||
if collectError(err) {
|
||||
break outerLoop
|
||||
}
|
||||
to, err := parseDateFromRequest(rangeSplit[1])
|
||||
if collectError(err) {
|
||||
break outerLoop
|
||||
}
|
||||
|
||||
err = addRange(from, to, filter, hll)
|
||||
if collectError(err) {
|
||||
break outerLoop
|
||||
}
|
||||
} else {
|
||||
collectError(errRangeFormatIncorrect)
|
||||
break outerLoop
|
||||
}
|
||||
}
|
||||
|
||||
if result.Status == statusOk {
|
||||
result.Count = hll.Count()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var errBadDate = errors.New("bad date format, must be yyyy-mm-dd")
|
||||
var zeroTime = time.Unix(0, 0)
|
||||
|
||||
func parseDateFromRequest(dateStr string) (time.Time, error) {
|
||||
var year, month, day int
|
||||
n, err := fmt.Sscanf(dateStr, "%d-%d-%d", &year, &month, &day)
|
||||
if err != nil || n != 3 {
|
||||
return zeroTime, errBadDate
|
||||
}
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, server.CounterLocation), nil
|
||||
}
|
||||
|
||||
type hllAndError struct {
|
||||
hll *hyperloglog.HyperLogLogPlus
|
||||
err error
|
||||
}
|
||||
|
||||
func addSingleDate(at time.Time, filter serverFilter, dest *hyperloglog.HyperLogLogPlus) error {
|
||||
var partialErr error
|
||||
for _, si := range allServers {
|
||||
if filter.IsServerAllowed(si) {
|
||||
hll, err2 := si.GetHLL(at)
|
||||
if err2 == ErrServerInFailedState {
|
||||
partialErr = err2
|
||||
} else if err2 != nil {
|
||||
return err2
|
||||
} else {
|
||||
dest.Merge(hll)
|
||||
}
|
||||
}
|
||||
}
|
||||
return partialErr
|
||||
}
|
||||
|
||||
func addRange(start time.Time, end time.Time, filter serverFilter, dest *hyperloglog.HyperLogLogPlus) error {
|
||||
end = server.TruncateToMidnight(end)
|
||||
year, month, day := start.Date()
|
||||
var partialErr error
|
||||
var myAllServers = make([]*serverInfo, 0, len(allServers))
|
||||
for _, si := range allServers {
|
||||
if filter.IsServerAllowed(si) {
|
||||
myAllServers = append(myAllServers, si)
|
||||
}
|
||||
}
|
||||
|
||||
var ch = make(chan hllAndError)
|
||||
var wg sync.WaitGroup
|
||||
for current := start; current.Before(end); day = day + 1 {
|
||||
current = time.Date(year, month, day, 0, 0, 0, 0, server.CounterLocation)
|
||||
for _, si := range myAllServers {
|
||||
wg.Add(1)
|
||||
go getHLL(ch, si, current)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
for pair := range ch {
|
||||
wg.Done()
|
||||
hll, err := pair.hll, pair.err
|
||||
if err != nil {
|
||||
if partialErr == nil || partialErr == ErrServerInFailedState {
|
||||
partialErr = err
|
||||
}
|
||||
} else {
|
||||
dest.Merge(hll)
|
||||
}
|
||||
}
|
||||
|
||||
return partialErr
|
||||
}
|
||||
|
||||
func getHLL(ch chan hllAndError, si *serverInfo, at time.Time) {
|
||||
hll, err := si.GetHLL(at)
|
||||
ch <- hllAndError{hll: hll, err: err}
|
||||
}
|
6
socketserver/cmd/statsweb/webroot/cal_entry.hbs
Normal file
6
socketserver/cmd/statsweb/webroot/cal_entry.hbs
Normal file
|
@ -0,0 +1,6 @@
|
|||
<td class="calentry {{if .NoData}}no_data{{end}}">
|
||||
<span class="date">{{.Date}}</span>
|
||||
{{if not .NoData}}
|
||||
<span class="uniqusers">{{.UniqUsers}}</span>
|
||||
{{end}}
|
||||
</td>
|
18
socketserver/cmd/statsweb/webroot/calendar.hbs
Normal file
18
socketserver/cmd/statsweb/webroot/calendar.hbs
Normal file
|
@ -0,0 +1,18 @@
|
|||
<table class="calendar">
|
||||
<thead>
|
||||
<th>Sunday</th>
|
||||
<th>Monday</th>
|
||||
<th>Tuesday</th>
|
||||
<th>Wednesday</th>
|
||||
<th>Thursday</th>
|
||||
<th>Friday</th>
|
||||
<th>Saturday</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Weeks}}
|
||||
{{range .Days}}
|
||||
{{template "cal_entry"}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
15
socketserver/cmd/statsweb/webroot/layout.template.html
Normal file
15
socketserver/cmd/statsweb/webroot/layout.template.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Socket Server Stats Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
{{template "header"}}
|
||||
</div>
|
||||
<div id="main">
|
||||
{{template "content"}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,234 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pmylund/go-cache"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var backendHttpClient http.Client
|
||||
var backendUrl string
|
||||
var responseCache *cache.Cache
|
||||
|
||||
var getBacklogUrl string
|
||||
|
||||
var backendSharedKey [32]byte
|
||||
var serverId int
|
||||
|
||||
var messageBufferPool sync.Pool
|
||||
|
||||
func SetupBackend(config *ConfigFile) {
|
||||
backendHttpClient.Timeout = 60 * time.Second
|
||||
backendUrl = config.BackendUrl
|
||||
if responseCache != nil {
|
||||
responseCache.Flush()
|
||||
}
|
||||
responseCache = cache.New(60*time.Second, 120*time.Second)
|
||||
|
||||
getBacklogUrl = fmt.Sprintf("%s/backlog", backendUrl)
|
||||
|
||||
messageBufferPool.New = New4KByteBuffer
|
||||
|
||||
var theirPublic, ourPrivate [32]byte
|
||||
copy(theirPublic[:], config.BackendPublicKey)
|
||||
copy(ourPrivate[:], config.OurPrivateKey)
|
||||
serverId = config.ServerId
|
||||
|
||||
box.Precompute(&backendSharedKey, &theirPublic, &ourPrivate)
|
||||
}
|
||||
|
||||
func getCacheKey(remoteCommand, data string) string {
|
||||
return fmt.Sprintf("%s/%s", remoteCommand, data)
|
||||
}
|
||||
|
||||
// Publish a message to clients with no caching.
|
||||
// The scope must be specified because no attempt is made to recognize the command.
|
||||
func HBackendPublishRequest(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := formData.Get("cmd")
|
||||
json := formData.Get("args")
|
||||
channel := formData.Get("channel")
|
||||
scope := formData.Get("scope")
|
||||
|
||||
target := MessageTargetTypeByName(scope)
|
||||
|
||||
if cmd == "" {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "Error: cmd cannot be blank")
|
||||
return
|
||||
}
|
||||
if channel == "" && (target == MsgTargetTypeChat || target == MsgTargetTypeMultichat) {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "Error: channel must be specified")
|
||||
return
|
||||
}
|
||||
|
||||
cm := ClientMessage{MessageID: -1, Command: Command(cmd), origArguments: json}
|
||||
cm.parseOrigArguments()
|
||||
var count int
|
||||
|
||||
switch target {
|
||||
case MsgTargetTypeSingle:
|
||||
// TODO
|
||||
case MsgTargetTypeChat:
|
||||
count = PublishToChat(channel, cm)
|
||||
case MsgTargetTypeMultichat:
|
||||
// TODO
|
||||
case MsgTargetTypeGlobal:
|
||||
count = PublishToAll(cm)
|
||||
case MsgTargetTypeInvalid:
|
||||
default:
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprint(w, "Invalid 'scope'. must be single, chat, multichat, channel, or global")
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, count)
|
||||
}
|
||||
|
||||
func RequestRemoteDataCached(remoteCommand, data string, auth AuthInfo) (string, error) {
|
||||
cached, ok := responseCache.Get(getCacheKey(remoteCommand, data))
|
||||
if ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
return RequestRemoteData(remoteCommand, data, auth)
|
||||
}
|
||||
|
||||
func RequestRemoteData(remoteCommand, data string, auth AuthInfo) (responseStr string, err error) {
|
||||
destUrl := fmt.Sprintf("%s/cmd/%s", backendUrl, remoteCommand)
|
||||
var authKey string
|
||||
if auth.UsernameValidated {
|
||||
authKey = "usernameClaimed"
|
||||
} else {
|
||||
authKey = "username"
|
||||
}
|
||||
|
||||
formData := url.Values{
|
||||
"clientData": []string{data},
|
||||
authKey: []string{auth.TwitchUsername},
|
||||
}
|
||||
|
||||
sealedForm, err := SealRequest(formData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := backendHttpClient.PostForm(destUrl, sealedForm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
responseStr = string(respBytes)
|
||||
|
||||
if resp.Header.Get("FFZ-Cache") != "" {
|
||||
durSecs, err := strconv.ParseInt(resp.Header.Get("FFZ-Cache"), 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("The RPC server returned a non-integer cache duration: %v", err)
|
||||
}
|
||||
duration := time.Duration(durSecs) * time.Second
|
||||
responseCache.Set(getCacheKey(remoteCommand, data), responseStr, duration)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func FetchBacklogData(chatSubs []string) ([]ClientMessage, error) {
|
||||
formData := url.Values{
|
||||
"subs": chatSubs,
|
||||
}
|
||||
|
||||
sealedForm, err := SealRequest(formData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := backendHttpClient.PostForm(getBacklogUrl, sealedForm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
var messages []ClientMessage
|
||||
err = dec.Decode(messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func GenerateKeys(outputFile, serverId, theirPublicStr string) {
|
||||
var err error
|
||||
output := ConfigFile{
|
||||
ListenAddr: "0.0.0.0:8001",
|
||||
SocketOrigin: "localhost:8001",
|
||||
BackendUrl: "http://localhost:8002/ffz",
|
||||
BannerHTML: `
|
||||
<!DOCTYPE html>
|
||||
<title>CatBag</title>
|
||||
<link rel="stylesheet" href="//cdn.frankerfacez.com/script/catbag.css">
|
||||
<div id="container">
|
||||
<div id="zf0"></div><div id="zf1"></div><div id="zf2"></div>
|
||||
<div id="zf3"></div><div id="zf4"></div><div id="zf5"></div>
|
||||
<div id="zf6"></div><div id="zf7"></div><div id="zf8"></div>
|
||||
<div id="zf9"></div><div id="catbag"></div>
|
||||
<div id="bottom">
|
||||
A <a href="http://www.frankerfacez.com/">FrankerFaceZ</a> Service
|
||||
— CatBag by <a href="http://www.twitch.tv/wolsk">Wolsk</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
output.ServerId, err = strconv.Atoi(serverId)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ourPublic, ourPrivate, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
output.OurPublicKey, output.OurPrivateKey = ourPublic[:], ourPrivate[:]
|
||||
|
||||
if theirPublicStr != "" {
|
||||
reader := base64.NewDecoder(base64.StdEncoding, strings.NewReader(theirPublicStr))
|
||||
theirPublic, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
output.BackendPublicKey = theirPublic
|
||||
}
|
||||
|
||||
bytes, err := json.MarshalIndent(output, "", "\t")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(bytes))
|
||||
err = ioutil.WriteFile(outputFile, bytes, 0600)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func SetupRandomKeys(t testing.TB) {
|
||||
_, senderPrivate, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receiverPublic, _, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
box.Precompute(&backendSharedKey, receiverPublic, senderPrivate)
|
||||
messageBufferPool.New = New4KByteBuffer
|
||||
}
|
||||
|
||||
func TestSealRequest(t *testing.T) {
|
||||
SetupRandomKeys(t)
|
||||
|
||||
values := url.Values{
|
||||
"QuickBrownFox": []string{"LazyDog"},
|
||||
}
|
||||
|
||||
sealedValues, err := SealRequest(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// sealedValues.Encode()
|
||||
// id=0&msg=KKtbng49dOLLyjeuX5AnXiEe6P0uZwgeP_7mMB5vhP-wMAAPZw%3D%3D&nonce=-wRbUnifscisWUvhm3gBEXHN5QzrfzgV
|
||||
|
||||
unsealedValues, err := UnsealRequest(sealedValues)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if unsealedValues.Get("QuickBrownFox") != "LazyDog" {
|
||||
t.Errorf("Failed to round-trip, got back %v", unsealedValues)
|
||||
}
|
||||
}
|
|
@ -1,364 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PushCommandCacheInfo struct {
|
||||
Caching BacklogCacheType
|
||||
Target MessageTargetType
|
||||
}
|
||||
|
||||
// this value is just docs right now
|
||||
var ServerInitiatedCommands = map[Command]PushCommandCacheInfo{
|
||||
/// Global updates & notices
|
||||
"update_news": {CacheTypeTimestamps, MsgTargetTypeGlobal}, // timecache:global
|
||||
"message": {CacheTypeTimestamps, MsgTargetTypeGlobal}, // timecache:global
|
||||
"reload_ff": {CacheTypeTimestamps, MsgTargetTypeGlobal}, // timecache:global
|
||||
|
||||
/// Emote updates
|
||||
"reload_badges": {CacheTypeTimestamps, MsgTargetTypeGlobal}, // timecache:global
|
||||
"set_badge": {CacheTypeTimestamps, MsgTargetTypeMultichat}, // timecache:multichat
|
||||
"reload_set": {}, // timecache:multichat
|
||||
"load_set": {}, // TODO what are the semantics of this?
|
||||
|
||||
/// User auth
|
||||
"do_authorize": {CacheTypeNever, MsgTargetTypeSingle}, // nocache:single
|
||||
|
||||
/// Channel data
|
||||
// follow_sets: extra emote sets included in the chat
|
||||
// follow_buttons: extra follow buttons below the stream
|
||||
"follow_sets": {CacheTypePersistent, MsgTargetTypeChat}, // mustcache:chat
|
||||
"follow_buttons": {CacheTypePersistent, MsgTargetTypeChat}, // mustcache:watching
|
||||
"srl_race": {CacheTypeLastOnly, MsgTargetTypeChat}, // cachelast:watching
|
||||
|
||||
/// Chatter/viewer counts
|
||||
"chatters": {CacheTypeLastOnly, MsgTargetTypeChat}, // cachelast:watching
|
||||
"viewers": {CacheTypeLastOnly, MsgTargetTypeChat}, // cachelast:watching
|
||||
}
|
||||
|
||||
type BacklogCacheType int
|
||||
|
||||
const (
|
||||
// This is not a cache type.
|
||||
CacheTypeInvalid BacklogCacheType = iota
|
||||
// This message cannot be cached.
|
||||
CacheTypeNever
|
||||
// Save the last 24 hours of this message.
|
||||
// If a client indicates that it has reconnected, replay the messages sent after the disconnect.
|
||||
// Do not replay if the client indicates that this is a firstload.
|
||||
CacheTypeTimestamps
|
||||
// Save only the last copy of this message, and always send it when the backlog is requested.
|
||||
CacheTypeLastOnly
|
||||
// Save this backlog data to disk with its timestamp.
|
||||
// Send it when the backlog is requested, or after a reconnect if it was updated.
|
||||
CacheTypePersistent
|
||||
)
|
||||
|
||||
type MessageTargetType int
|
||||
|
||||
const (
|
||||
// This is not a message target.
|
||||
MsgTargetTypeInvalid MessageTargetType = iota
|
||||
// This message is targeted to a single TODO(user or connection)
|
||||
MsgTargetTypeSingle
|
||||
// This message is targeted to all users in a chat
|
||||
MsgTargetTypeChat
|
||||
// This message is targeted to all users in multiple chats
|
||||
MsgTargetTypeMultichat
|
||||
// This message is sent to all FFZ users.
|
||||
MsgTargetTypeGlobal
|
||||
)
|
||||
|
||||
// note: see types.go for methods on these
|
||||
|
||||
// Returned by BacklogCacheType.UnmarshalJSON()
|
||||
var ErrorUnrecognizedCacheType = errors.New("Invalid value for cachetype")
|
||||
|
||||
// Returned by MessageTargetType.UnmarshalJSON()
|
||||
var ErrorUnrecognizedTargetType = errors.New("Invalid value for message target")
|
||||
|
||||
type TimestampedGlobalMessage struct {
|
||||
Timestamp time.Time
|
||||
Command Command
|
||||
Data string
|
||||
}
|
||||
|
||||
type TimestampedMultichatMessage struct {
|
||||
Timestamp time.Time
|
||||
Channels []string
|
||||
Command Command
|
||||
Data string
|
||||
}
|
||||
|
||||
type LastSavedMessage struct {
|
||||
Timestamp time.Time
|
||||
Data string
|
||||
}
|
||||
|
||||
// map is command -> channel -> data
|
||||
|
||||
// CacheTypeLastOnly. Cleaned up by reaper goroutine every ~hour.
|
||||
var CachedLastMessages map[Command]map[string]LastSavedMessage
|
||||
var CachedLSMLock sync.RWMutex
|
||||
|
||||
// CacheTypePersistent. Never cleaned.
|
||||
var PersistentLastMessages map[Command]map[string]LastSavedMessage
|
||||
var PersistentLSMLock sync.RWMutex
|
||||
|
||||
var CachedGlobalMessages []TimestampedGlobalMessage
|
||||
var CachedChannelMessages []TimestampedMultichatMessage
|
||||
var CacheListsLock sync.RWMutex
|
||||
|
||||
func DumpCache() {
|
||||
CachedLSMLock.Lock()
|
||||
CachedLastMessages = make(map[Command]map[string]LastSavedMessage)
|
||||
CachedLSMLock.Unlock()
|
||||
|
||||
PersistentLSMLock.Lock()
|
||||
PersistentLastMessages = make(map[Command]map[string]LastSavedMessage)
|
||||
// TODO delete file?
|
||||
PersistentLSMLock.Unlock()
|
||||
|
||||
CacheListsLock.Lock()
|
||||
CachedGlobalMessages = make(tgmarray, 0)
|
||||
CachedChannelMessages = make(tmmarray, 0)
|
||||
CacheListsLock.Unlock()
|
||||
}
|
||||
|
||||
func SendBacklogForNewClient(client *ClientInfo) {
|
||||
client.Mutex.Lock() // reading CurrentChannels
|
||||
PersistentLSMLock.RLock()
|
||||
for _, cmd := range GetCommandsOfType(PushCommandCacheInfo{CacheTypePersistent, MsgTargetTypeChat}) {
|
||||
chanMap := CachedLastMessages[cmd]
|
||||
if chanMap == nil {
|
||||
continue
|
||||
}
|
||||
for _, channel := range client.CurrentChannels {
|
||||
msg, ok := chanMap[channel]
|
||||
if ok {
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: msg.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
PersistentLSMLock.RUnlock()
|
||||
|
||||
CachedLSMLock.RLock()
|
||||
for _, cmd := range GetCommandsOfType(PushCommandCacheInfo{CacheTypeLastOnly, MsgTargetTypeChat}) {
|
||||
chanMap := CachedLastMessages[cmd]
|
||||
if chanMap == nil {
|
||||
continue
|
||||
}
|
||||
for _, channel := range client.CurrentChannels {
|
||||
msg, ok := chanMap[channel]
|
||||
if ok {
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: msg.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
CachedLSMLock.RUnlock()
|
||||
client.Mutex.Unlock()
|
||||
}
|
||||
|
||||
func SendTimedBacklogMessages(client *ClientInfo, disconnectTime time.Time) {
|
||||
client.Mutex.Lock() // reading CurrentChannels
|
||||
CacheListsLock.RLock()
|
||||
|
||||
globIdx := FindFirstNewMessage(tgmarray(CachedGlobalMessages), disconnectTime)
|
||||
|
||||
for i := globIdx; i < len(CachedGlobalMessages); i++ {
|
||||
item := CachedGlobalMessages[i]
|
||||
msg := ClientMessage{MessageID: -1, Command: item.Command, origArguments: item.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
|
||||
chanIdx := FindFirstNewMessage(tmmarray(CachedChannelMessages), disconnectTime)
|
||||
|
||||
for i := chanIdx; i < len(CachedChannelMessages); i++ {
|
||||
item := CachedChannelMessages[i]
|
||||
var send bool
|
||||
for _, channel := range item.Channels {
|
||||
for _, matchChannel := range client.CurrentChannels {
|
||||
if channel == matchChannel {
|
||||
send = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if send {
|
||||
break
|
||||
}
|
||||
}
|
||||
if send {
|
||||
msg := ClientMessage{MessageID: -1, Command: item.Command, origArguments: item.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
|
||||
CacheListsLock.RUnlock()
|
||||
client.Mutex.Unlock()
|
||||
}
|
||||
|
||||
func InsertionSort(ary sort.Interface) {
|
||||
for i := 1; i < ary.Len(); i++ {
|
||||
for j := i; j > 0 && ary.Less(j, j-1); j-- {
|
||||
ary.Swap(j, j-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TimestampArray interface {
|
||||
Len() int
|
||||
GetTime(int) time.Time
|
||||
}
|
||||
|
||||
func FindFirstNewMessage(ary TimestampArray, disconnectTime time.Time) (idx int) {
|
||||
// TODO needs tests
|
||||
len := ary.Len()
|
||||
i := len
|
||||
|
||||
// Walk backwards until we find GetTime() before disconnectTime
|
||||
step := 1
|
||||
for i > 0 {
|
||||
i -= step
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
if !ary.GetTime(i).After(disconnectTime) {
|
||||
break
|
||||
}
|
||||
step = int(float64(step)*1.5) + 1
|
||||
}
|
||||
|
||||
// Walk forwards until we find GetTime() after disconnectTime
|
||||
for i < len && !ary.GetTime(i).After(disconnectTime) {
|
||||
i++
|
||||
}
|
||||
|
||||
if i == len {
|
||||
return -1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func SaveLastMessage(which map[Command]map[string]LastSavedMessage, locker sync.Locker, cmd Command, channel string, timestamp time.Time, data string, deleting bool) {
|
||||
locker.Lock()
|
||||
defer locker.Unlock()
|
||||
|
||||
chanMap, ok := CachedLastMessages[cmd]
|
||||
if !ok {
|
||||
if deleting {
|
||||
return
|
||||
}
|
||||
chanMap = make(map[string]LastSavedMessage)
|
||||
CachedLastMessages[cmd] = chanMap
|
||||
}
|
||||
|
||||
if deleting {
|
||||
delete(chanMap, channel)
|
||||
} else {
|
||||
chanMap[channel] = LastSavedMessage{timestamp, data}
|
||||
}
|
||||
}
|
||||
|
||||
func SaveGlobalMessage(cmd Command, timestamp time.Time, data string) {
|
||||
CacheListsLock.Lock()
|
||||
CachedGlobalMessages = append(CachedGlobalMessages, TimestampedGlobalMessage{timestamp, cmd, data})
|
||||
InsertionSort(tgmarray(CachedGlobalMessages))
|
||||
CacheListsLock.Unlock()
|
||||
}
|
||||
|
||||
func SaveMultichanMessage(cmd Command, channels string, timestamp time.Time, data string) {
|
||||
CacheListsLock.Lock()
|
||||
CachedChannelMessages = append(CachedChannelMessages, TimestampedMultichatMessage{timestamp, strings.Split(channels, ","), cmd, data})
|
||||
InsertionSort(tmmarray(CachedChannelMessages))
|
||||
CacheListsLock.Unlock()
|
||||
}
|
||||
|
||||
func GetCommandsOfType(match PushCommandCacheInfo) []Command {
|
||||
var ret []Command
|
||||
for cmd, info := range ServerInitiatedCommands {
|
||||
if info == match {
|
||||
ret = append(ret, cmd)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func HBackendDumpBacklog(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
confirm := formData.Get("confirm")
|
||||
if confirm == "1" {
|
||||
DumpCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a message to clients, and update the in-server cache for the message.
|
||||
// notes:
|
||||
// `scope` is implicit in the command
|
||||
func HBackendUpdateAndPublish(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := Command(formData.Get("cmd"))
|
||||
json := formData.Get("args")
|
||||
channel := formData.Get("channel")
|
||||
deleteMode := formData.Get("delete") != ""
|
||||
timeStr := formData.Get("time")
|
||||
timestamp, err := time.Parse(time.UnixDate, timeStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "error parsing time: %v", err)
|
||||
}
|
||||
|
||||
cacheinfo, ok := ServerInitiatedCommands[cmd]
|
||||
if !ok {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "Caching semantics unknown for command '%s'. Post to /addcachedcommand first.")
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: json}
|
||||
msg.parseOrigArguments()
|
||||
|
||||
if cacheinfo.Caching == CacheTypeLastOnly && cacheinfo.Target == MsgTargetTypeChat {
|
||||
SaveLastMessage(CachedLastMessages, &CachedLSMLock, cmd, channel, timestamp, json, deleteMode)
|
||||
count = PublishToChat(channel, msg)
|
||||
} else if cacheinfo.Caching == CacheTypePersistent && cacheinfo.Target == MsgTargetTypeChat {
|
||||
SaveLastMessage(PersistentLastMessages, &PersistentLSMLock, cmd, channel, timestamp, json, deleteMode)
|
||||
count = PublishToChat(channel, msg)
|
||||
} else if cacheinfo.Caching == CacheTypeTimestamps && cacheinfo.Target == MsgTargetTypeMultichat {
|
||||
SaveMultichanMessage(cmd, channel, timestamp, json)
|
||||
count = PublishToMultiple(strings.Split(channel, ","), msg)
|
||||
} else if cacheinfo.Caching == CacheTypeTimestamps && cacheinfo.Target == MsgTargetTypeGlobal {
|
||||
SaveGlobalMessage(cmd, timestamp, json)
|
||||
count = PublishToAll(msg)
|
||||
}
|
||||
|
||||
w.Write([]byte(strconv.Itoa(count)))
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindFirstNewMessageEmpty(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != -1 {
|
||||
t.Errorf("Expected -1, got %d", i)
|
||||
}
|
||||
}
|
||||
func TestFindFirstNewMessageOneBefore(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{
|
||||
{Timestamp: time.Unix(8, 0)},
|
||||
}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != -1 {
|
||||
t.Errorf("Expected -1, got %d", i)
|
||||
}
|
||||
}
|
||||
func TestFindFirstNewMessageSeveralBefore(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{
|
||||
{Timestamp: time.Unix(1, 0)},
|
||||
{Timestamp: time.Unix(2, 0)},
|
||||
{Timestamp: time.Unix(3, 0)},
|
||||
{Timestamp: time.Unix(4, 0)},
|
||||
{Timestamp: time.Unix(5, 0)},
|
||||
}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != -1 {
|
||||
t.Errorf("Expected -1, got %d", i)
|
||||
}
|
||||
}
|
||||
func TestFindFirstNewMessageInMiddle(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{
|
||||
{Timestamp: time.Unix(1, 0)},
|
||||
{Timestamp: time.Unix(2, 0)},
|
||||
{Timestamp: time.Unix(3, 0)},
|
||||
{Timestamp: time.Unix(4, 0)},
|
||||
{Timestamp: time.Unix(5, 0)},
|
||||
{Timestamp: time.Unix(11, 0)},
|
||||
{Timestamp: time.Unix(12, 0)},
|
||||
{Timestamp: time.Unix(13, 0)},
|
||||
{Timestamp: time.Unix(14, 0)},
|
||||
{Timestamp: time.Unix(15, 0)},
|
||||
}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != 5 {
|
||||
t.Errorf("Expected 5, got %d", i)
|
||||
}
|
||||
}
|
||||
func TestFindFirstNewMessageOneAfter(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{
|
||||
{Timestamp: time.Unix(15, 0)},
|
||||
}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != 0 {
|
||||
t.Errorf("Expected 0, got %d", i)
|
||||
}
|
||||
}
|
||||
func TestFindFirstNewMessageSeveralAfter(t *testing.T) {
|
||||
CachedGlobalMessages = []TimestampedGlobalMessage{
|
||||
{Timestamp: time.Unix(11, 0)},
|
||||
{Timestamp: time.Unix(12, 0)},
|
||||
{Timestamp: time.Unix(13, 0)},
|
||||
{Timestamp: time.Unix(14, 0)},
|
||||
{Timestamp: time.Unix(15, 0)},
|
||||
}
|
||||
i := FindFirstNewMessage(tgmarray(CachedGlobalMessages), time.Unix(10, 0))
|
||||
if i != 0 {
|
||||
t.Errorf("Expected 0, got %d", i)
|
||||
}
|
||||
}
|
|
@ -1,285 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/satori/go.uuid"
|
||||
"golang.org/x/net/websocket"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ResponseSuccess = ClientMessage{Command: SuccessCommand}
|
||||
var ResponseFailure = ClientMessage{Command: "False"}
|
||||
|
||||
const ChannelInfoDelay = 2 * time.Second
|
||||
|
||||
func HandleCommand(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) {
|
||||
handler, ok := CommandHandlers[msg.Command]
|
||||
if !ok {
|
||||
log.Println("[!] Unknown command", msg.Command, "- sent by client", client.ClientID, "@", conn.RemoteAddr())
|
||||
FFZCodec.Send(conn, ClientMessage{
|
||||
MessageID: msg.MessageID,
|
||||
Command: "error",
|
||||
Arguments: fmt.Sprintf("Unknown command %s", msg.Command),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response, err := CallHandler(handler, conn, client, msg)
|
||||
|
||||
if err == nil {
|
||||
if response.Command == AsyncResponseCommand {
|
||||
// Don't send anything
|
||||
// The response will be delivered over client.MessageChannel / serverMessageChan
|
||||
} else {
|
||||
response.MessageID = msg.MessageID
|
||||
FFZCodec.Send(conn, response)
|
||||
}
|
||||
} else {
|
||||
FFZCodec.Send(conn, ClientMessage{
|
||||
MessageID: msg.MessageID,
|
||||
Command: "error",
|
||||
Arguments: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func HandleHello(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
version, clientId, err := msg.ArgumentsAsTwoStrings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client.Version = version
|
||||
client.ClientID = uuid.FromStringOrNil(clientId)
|
||||
if client.ClientID == uuid.Nil {
|
||||
client.ClientID = uuid.NewV4()
|
||||
}
|
||||
|
||||
SubscribeGlobal(client)
|
||||
|
||||
return ClientMessage{
|
||||
Arguments: client.ClientID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func HandleReady(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
disconnectAt, err := msg.ArgumentsAsInt()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client.Mutex.Lock()
|
||||
if client.MakePendingRequests != nil {
|
||||
if !client.MakePendingRequests.Stop() {
|
||||
// Timer already fired, GetSubscriptionBacklog() has started
|
||||
rmsg.Command = SuccessCommand
|
||||
return
|
||||
}
|
||||
}
|
||||
client.PendingSubscriptionsBacklog = nil
|
||||
client.MakePendingRequests = nil
|
||||
client.Mutex.Unlock()
|
||||
|
||||
if disconnectAt == 0 {
|
||||
// backlog only
|
||||
go func() {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: SuccessCommand}
|
||||
SendBacklogForNewClient(client)
|
||||
}()
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
} else {
|
||||
// backlog and timed
|
||||
go func() {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: SuccessCommand}
|
||||
SendBacklogForNewClient(client)
|
||||
SendTimedBacklogMessages(client, time.Unix(disconnectAt, 0))
|
||||
}()
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSetUser(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
username, err := msg.ArgumentsAsString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client.Mutex.Lock()
|
||||
client.TwitchUsername = username
|
||||
client.UsernameValidated = false
|
||||
client.Mutex.Unlock()
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
func HandleSub(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, err := msg.ArgumentsAsString()
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client.Mutex.Lock()
|
||||
|
||||
AddToSliceS(&client.CurrentChannels, channel)
|
||||
client.PendingSubscriptionsBacklog = append(client.PendingSubscriptionsBacklog, channel)
|
||||
|
||||
if client.MakePendingRequests == nil {
|
||||
client.MakePendingRequests = time.AfterFunc(ChannelInfoDelay, GetSubscriptionBacklogFor(conn, client))
|
||||
} else {
|
||||
if !client.MakePendingRequests.Reset(ChannelInfoDelay) {
|
||||
client.MakePendingRequests = time.AfterFunc(ChannelInfoDelay, GetSubscriptionBacklogFor(conn, client))
|
||||
}
|
||||
}
|
||||
|
||||
client.Mutex.Unlock()
|
||||
|
||||
SubscribeChat(client, channel)
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
func HandleUnsub(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, err := msg.ArgumentsAsString()
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client.Mutex.Lock()
|
||||
RemoveFromSliceS(&client.CurrentChannels, channel)
|
||||
client.Mutex.Unlock()
|
||||
|
||||
UnsubscribeSingleChat(client, channel)
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
func GetSubscriptionBacklogFor(conn *websocket.Conn, client *ClientInfo) func() {
|
||||
return func() {
|
||||
GetSubscriptionBacklog(conn, client)
|
||||
}
|
||||
}
|
||||
|
||||
// On goroutine
|
||||
func GetSubscriptionBacklog(conn *websocket.Conn, client *ClientInfo) {
|
||||
var subs []string
|
||||
|
||||
// Lock, grab the data, and reset it
|
||||
client.Mutex.Lock()
|
||||
subs = client.PendingSubscriptionsBacklog
|
||||
client.PendingSubscriptionsBacklog = nil
|
||||
client.MakePendingRequests = nil
|
||||
client.Mutex.Unlock()
|
||||
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if backendUrl == "" {
|
||||
return // for testing runs
|
||||
}
|
||||
messages, err := FetchBacklogData(subs)
|
||||
|
||||
if err != nil {
|
||||
// Oh well.
|
||||
log.Print("error in GetSubscriptionBacklog:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Deliver to client
|
||||
for _, msg := range messages {
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
|
||||
type SurveySubmission struct {
|
||||
User string
|
||||
Json string
|
||||
}
|
||||
|
||||
var SurveySubmissions []SurveySubmission
|
||||
var SurveySubmissionLock sync.Mutex
|
||||
|
||||
func HandleSurvey(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
SurveySubmissionLock.Lock()
|
||||
SurveySubmissions = append(SurveySubmissions, SurveySubmission{client.TwitchUsername, msg.origArguments})
|
||||
SurveySubmissionLock.Unlock()
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
type FollowEvent struct {
|
||||
User string
|
||||
Channel string
|
||||
NowFollowing bool
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var FollowEvents []FollowEvent
|
||||
var FollowEventsLock sync.Mutex
|
||||
|
||||
func HandleTrackFollow(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, following, err := msg.ArgumentsAsStringAndBool()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
FollowEventsLock.Lock()
|
||||
FollowEvents = append(FollowEvents, FollowEvent{client.TwitchUsername, channel, following, now})
|
||||
FollowEventsLock.Unlock()
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
var AggregateEmoteUsage map[int]map[string]int = make(map[int]map[string]int)
|
||||
var AggregateEmoteUsageLock sync.Mutex
|
||||
|
||||
func HandleEmoticonUses(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
// arguments is [1]map[EmoteId]map[RoomName]float64
|
||||
|
||||
mapRoot := msg.Arguments.([]interface{})[0].(map[string]interface{})
|
||||
|
||||
AggregateEmoteUsageLock.Lock()
|
||||
defer AggregateEmoteUsageLock.Unlock()
|
||||
|
||||
for strEmote, val1 := range mapRoot {
|
||||
var emoteId int
|
||||
emoteId, err = strconv.Atoi(strEmote)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
destMapInner, ok := AggregateEmoteUsage[emoteId]
|
||||
if !ok {
|
||||
destMapInner = make(map[string]int)
|
||||
AggregateEmoteUsage[emoteId] = destMapInner
|
||||
}
|
||||
|
||||
mapInner := val1.(map[string]interface{})
|
||||
for roomName, val2 := range mapInner {
|
||||
var count int = int(val2.(float64))
|
||||
destMapInner[roomName] += count
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
func HandleRemoteCommand(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
go func(conn *websocket.Conn, msg ClientMessage, authInfo AuthInfo) {
|
||||
resp, err := RequestRemoteDataCached(string(msg.Command), msg.origArguments, authInfo)
|
||||
|
||||
if err != nil {
|
||||
FFZCodec.Send(conn, ClientMessage{MessageID: msg.MessageID, Command: ErrorCommand, Arguments: err.Error()})
|
||||
} else {
|
||||
FFZCodec.Send(conn, ClientMessage{MessageID: msg.MessageID, Command: SuccessCommand, origArguments: resp})
|
||||
}
|
||||
}(conn, msg, client.AuthInfo)
|
||||
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
|
@ -1,434 +0,0 @@
|
|||
package server // import "bitbucket.org/stendec/frankerfacez/socketserver/internal/server"
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/net/websocket"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const MAX_PACKET_SIZE = 1024
|
||||
|
||||
// A command is how the client refers to a function on the server. It's just a string.
|
||||
type Command string
|
||||
|
||||
// A function that is called to respond to a Command.
|
||||
type CommandHandler func(*websocket.Conn, *ClientInfo, ClientMessage) (ClientMessage, error)
|
||||
|
||||
var CommandHandlers = map[Command]CommandHandler{
|
||||
HelloCommand: HandleHello,
|
||||
"setuser": HandleSetUser,
|
||||
"ready": HandleReady,
|
||||
|
||||
"sub": HandleSub,
|
||||
"unsub": HandleUnsub,
|
||||
|
||||
"track_follow": HandleTrackFollow,
|
||||
"emoticon_uses": HandleEmoticonUses,
|
||||
"survey": HandleSurvey,
|
||||
|
||||
"twitch_emote": HandleRemoteCommand,
|
||||
"get_link": HandleRemoteCommand,
|
||||
"get_display_name": HandleRemoteCommand,
|
||||
"update_follow_buttons": HandleRemoteCommand,
|
||||
"chat_history": HandleRemoteCommand,
|
||||
}
|
||||
|
||||
// Sent by the server in ClientMessage.Command to indicate success.
|
||||
const SuccessCommand Command = "True"
|
||||
|
||||
// Sent by the server in ClientMessage.Command to indicate failure.
|
||||
const ErrorCommand Command = "error"
|
||||
|
||||
// This must be the first command sent by the client once the connection is established.
|
||||
const HelloCommand Command = "hello"
|
||||
|
||||
// A handler returning a ClientMessage with this Command will prevent replying to the client.
|
||||
// It signals that the work has been handed off to a background goroutine.
|
||||
const AsyncResponseCommand Command = "_async"
|
||||
|
||||
// A websocket.Codec that translates the protocol into ClientMessage objects.
|
||||
var FFZCodec websocket.Codec = websocket.Codec{
|
||||
Marshal: MarshalClientMessage,
|
||||
Unmarshal: UnmarshalClientMessage,
|
||||
}
|
||||
|
||||
// Errors that get returned to the client.
|
||||
var ProtocolError error = errors.New("FFZ Socket protocol error.")
|
||||
var ProtocolErrorNegativeID error = errors.New("FFZ Socket protocol error: negative or zero message ID.")
|
||||
var ExpectedSingleString = errors.New("Error: Expected single string as arguments.")
|
||||
var ExpectedSingleInt = errors.New("Error: Expected single integer as arguments.")
|
||||
var ExpectedTwoStrings = errors.New("Error: Expected array of string, string as arguments.")
|
||||
var ExpectedStringAndInt = errors.New("Error: Expected array of string, int as arguments.")
|
||||
var ExpectedStringAndBool = errors.New("Error: Expected array of string, bool as arguments.")
|
||||
var ExpectedStringAndIntGotFloat = errors.New("Error: Second argument was a float, expected an integer.")
|
||||
|
||||
var gconfig *ConfigFile
|
||||
|
||||
// Create a websocket.Server with the options from the provided Config.
|
||||
func setupServer(config *ConfigFile, tlsConfig *tls.Config) *websocket.Server {
|
||||
gconfig = config
|
||||
sockConf, err := websocket.NewConfig("/", config.SocketOrigin)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
SetupBackend(config)
|
||||
|
||||
if config.UseSSL {
|
||||
cert, err := tls.LoadX509KeyPair(config.SSLCertificateFile, config.SSLKeyFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
tlsConfig.ServerName = config.SocketOrigin
|
||||
tlsConfig.BuildNameToCertificate()
|
||||
sockConf.TlsConfig = tlsConfig
|
||||
|
||||
}
|
||||
|
||||
sockServer := &websocket.Server{}
|
||||
sockServer.Config = *sockConf
|
||||
sockServer.Handler = HandleSocketConnection
|
||||
|
||||
go deadChannelReaper()
|
||||
|
||||
return sockServer
|
||||
}
|
||||
|
||||
// Set up a websocket listener and register it on /.
|
||||
// (Uses http.DefaultServeMux .)
|
||||
func SetupServerAndHandle(config *ConfigFile, tlsConfig *tls.Config, serveMux *http.ServeMux) {
|
||||
sockServer := setupServer(config, tlsConfig)
|
||||
|
||||
if serveMux == nil {
|
||||
serveMux = http.DefaultServeMux
|
||||
}
|
||||
serveMux.HandleFunc("/", ServeWebsocketOrCatbag(sockServer.ServeHTTP))
|
||||
serveMux.HandleFunc("/pub_msg", HBackendPublishRequest)
|
||||
serveMux.HandleFunc("/dump_backlog", HBackendDumpBacklog)
|
||||
serveMux.HandleFunc("/update_and_pub", HBackendUpdateAndPublish)
|
||||
}
|
||||
|
||||
func ServeWebsocketOrCatbag(sockfunc func(http.ResponseWriter, *http.Request)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Connection") == "Upgrade" {
|
||||
sockfunc(w, r)
|
||||
return
|
||||
} else {
|
||||
w.Write([]byte(gconfig.BannerHTML))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle a new websocket connection from a FFZ client.
|
||||
// This runs in a goroutine started by net/http.
|
||||
func HandleSocketConnection(conn *websocket.Conn) {
|
||||
// websocket.Conn is a ReadWriteCloser
|
||||
|
||||
var _closer sync.Once
|
||||
closer := func() {
|
||||
_closer.Do(func() {
|
||||
conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// Close the connection when we're done.
|
||||
defer closer()
|
||||
|
||||
_clientChan := make(chan ClientMessage)
|
||||
_serverMessageChan := make(chan ClientMessage)
|
||||
_errorChan := make(chan error)
|
||||
|
||||
// Launch receiver goroutine
|
||||
go func(errorChan chan<- error, clientChan chan<- ClientMessage) {
|
||||
var msg ClientMessage
|
||||
var err error
|
||||
for ; err == nil; err = FFZCodec.Receive(conn, &msg) {
|
||||
if msg.MessageID == 0 {
|
||||
continue
|
||||
}
|
||||
clientChan <- msg
|
||||
}
|
||||
errorChan <- err
|
||||
close(errorChan)
|
||||
close(clientChan)
|
||||
// exit
|
||||
}(_errorChan, _clientChan)
|
||||
|
||||
var errorChan <-chan error = _errorChan
|
||||
var clientChan <-chan ClientMessage = _clientChan
|
||||
var serverMessageChan <-chan ClientMessage = _serverMessageChan
|
||||
|
||||
var client ClientInfo
|
||||
client.MessageChannel = _serverMessageChan
|
||||
|
||||
// All set up, now enter the work loop
|
||||
|
||||
RunLoop:
|
||||
for {
|
||||
select {
|
||||
case err := <-errorChan:
|
||||
FFZCodec.Send(conn, ClientMessage{
|
||||
MessageID: -1,
|
||||
Command: "error",
|
||||
Arguments: err.Error(),
|
||||
}) // note - socket might be closed, but don't care
|
||||
break RunLoop
|
||||
case msg := <-clientChan:
|
||||
if client.Version == "" && msg.Command != HelloCommand {
|
||||
FFZCodec.Send(conn, ClientMessage{
|
||||
MessageID: msg.MessageID,
|
||||
Command: "error",
|
||||
Arguments: "Error - the first message sent must be a 'hello'",
|
||||
})
|
||||
break RunLoop
|
||||
}
|
||||
|
||||
HandleCommand(conn, &client, msg)
|
||||
case smsg := <-serverMessageChan:
|
||||
FFZCodec.Send(conn, smsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Exit
|
||||
|
||||
// Launch message draining goroutine - we aren't out of the pub/sub records
|
||||
go func() {
|
||||
for _ = range _serverMessageChan {
|
||||
}
|
||||
}()
|
||||
|
||||
// Stop getting messages...
|
||||
UnsubscribeAll(&client)
|
||||
|
||||
// And finished.
|
||||
// Close the channel so the draining goroutine can finish, too.
|
||||
close(_serverMessageChan)
|
||||
}
|
||||
|
||||
func CallHandler(handler CommandHandler, conn *websocket.Conn, client *ClientInfo, cmsg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
var ok bool
|
||||
fmt.Print("[!] Error executing command", cmsg.Command, "--", r)
|
||||
err, ok = r.(error)
|
||||
if !ok {
|
||||
err = fmt.Errorf("command handler: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return handler(conn, client, cmsg)
|
||||
}
|
||||
|
||||
// Unpack a message sent from the client into a ClientMessage.
|
||||
func UnmarshalClientMessage(data []byte, payloadType byte, v interface{}) (err error) {
|
||||
var spaceIdx int
|
||||
|
||||
out := v.(*ClientMessage)
|
||||
dataStr := string(data)
|
||||
|
||||
// Message ID
|
||||
spaceIdx = strings.IndexRune(dataStr, ' ')
|
||||
if spaceIdx == -1 {
|
||||
return ProtocolError
|
||||
}
|
||||
messageId, err := strconv.Atoi(dataStr[:spaceIdx])
|
||||
if messageId < -1 || messageId == 0 {
|
||||
return ProtocolErrorNegativeID
|
||||
}
|
||||
|
||||
out.MessageID = messageId
|
||||
dataStr = dataStr[spaceIdx+1:]
|
||||
|
||||
spaceIdx = strings.IndexRune(dataStr, ' ')
|
||||
if spaceIdx == -1 {
|
||||
out.Command = Command(dataStr)
|
||||
out.Arguments = nil
|
||||
return nil
|
||||
} else {
|
||||
out.Command = Command(dataStr[:spaceIdx])
|
||||
}
|
||||
dataStr = dataStr[spaceIdx+1:]
|
||||
argumentsJson := dataStr
|
||||
out.origArguments = argumentsJson
|
||||
err = out.parseOrigArguments()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *ClientMessage) parseOrigArguments() error {
|
||||
err := json.Unmarshal([]byte(cm.origArguments), &cm.Arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalClientMessage(clientMessage interface{}) (data []byte, payloadType byte, err error) {
|
||||
var msg ClientMessage
|
||||
var ok bool
|
||||
msg, ok = clientMessage.(ClientMessage)
|
||||
if !ok {
|
||||
pMsg, ok := clientMessage.(*ClientMessage)
|
||||
if !ok {
|
||||
panic("MarshalClientMessage: argument needs to be a ClientMessage")
|
||||
}
|
||||
msg = *pMsg
|
||||
}
|
||||
var dataStr string
|
||||
|
||||
if msg.Command == "" && msg.MessageID == 0 {
|
||||
panic("MarshalClientMessage: attempt to send an empty ClientMessage")
|
||||
}
|
||||
|
||||
if msg.Command == "" {
|
||||
msg.Command = SuccessCommand
|
||||
}
|
||||
if msg.MessageID == 0 {
|
||||
msg.MessageID = -1
|
||||
}
|
||||
|
||||
if msg.Arguments != nil {
|
||||
argBytes, err := json.Marshal(msg.Arguments)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
dataStr = fmt.Sprintf("%d %s %s", msg.MessageID, msg.Command, string(argBytes))
|
||||
} else {
|
||||
dataStr = fmt.Sprintf("%d %s", msg.MessageID, msg.Command)
|
||||
}
|
||||
|
||||
return []byte(dataStr), websocket.TextFrame, nil
|
||||
}
|
||||
|
||||
// Command handlers should use this to construct responses.
|
||||
func NewClientMessage(arguments interface{}) ClientMessage {
|
||||
return ClientMessage{
|
||||
MessageID: 0, // filled by the select loop
|
||||
Command: SuccessCommand,
|
||||
Arguments: arguments,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience method: Parse the arguments of the ClientMessage as a single string.
|
||||
func (cm *ClientMessage) ArgumentsAsString() (string1 string, err error) {
|
||||
var ok bool
|
||||
string1, ok = cm.Arguments.(string)
|
||||
if !ok {
|
||||
err = ExpectedSingleString
|
||||
return
|
||||
} else {
|
||||
return string1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience method: Parse the arguments of the ClientMessage as a single int.
|
||||
func (cm *ClientMessage) ArgumentsAsInt() (int1 int64, err error) {
|
||||
var ok bool
|
||||
var num float64
|
||||
num, ok = cm.Arguments.(float64)
|
||||
if !ok {
|
||||
err = ExpectedSingleInt
|
||||
return
|
||||
} else {
|
||||
int1 = int64(num)
|
||||
return int1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience method: Parse the arguments of the ClientMessage as an array of two strings.
|
||||
func (cm *ClientMessage) ArgumentsAsTwoStrings() (string1, string2 string, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ExpectedTwoStrings
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
string1, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
string2, ok = ary[1].(string)
|
||||
if !ok {
|
||||
err = ExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
return string1, string2, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience method: Parse the arguments of the ClientMessage as an array of a string and an int.
|
||||
func (cm *ClientMessage) ArgumentsAsStringAndInt() (string1 string, int int64, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ExpectedStringAndInt
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
string1, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
var num float64
|
||||
num, ok = ary[1].(float64)
|
||||
if !ok {
|
||||
err = ExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
int = int64(num)
|
||||
if float64(int) != num {
|
||||
err = ExpectedStringAndIntGotFloat
|
||||
return
|
||||
}
|
||||
return string1, int, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience method: Parse the arguments of the ClientMessage as an array of a string and an int.
|
||||
func (cm *ClientMessage) ArgumentsAsStringAndBool() (str string, flag bool, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ExpectedStringAndBool
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
str, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
flag, ok = ary[1].(bool)
|
||||
if !ok {
|
||||
err = ExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
return str, flag, nil
|
||||
}
|
||||
}
|
|
@ -1,232 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/satori/go.uuid"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CryptoBoxKeyLength = 32
|
||||
|
||||
type ConfigFile struct {
|
||||
// Numeric server id known to the backend
|
||||
ServerId int
|
||||
ListenAddr string
|
||||
// Hostname of the socket server
|
||||
SocketOrigin string
|
||||
// URL to the backend server
|
||||
BackendUrl string
|
||||
// Memes go here
|
||||
BannerHTML string
|
||||
|
||||
// SSL/TLS
|
||||
UseSSL bool
|
||||
SSLCertificateFile string
|
||||
SSLKeyFile string
|
||||
|
||||
// Nacl keys
|
||||
OurPrivateKey []byte
|
||||
OurPublicKey []byte
|
||||
BackendPublicKey []byte
|
||||
}
|
||||
|
||||
type ClientMessage struct {
|
||||
// Message ID. Increments by 1 for each message sent from the client.
|
||||
// When replying to a command, the message ID must be echoed.
|
||||
// When sending a server-initiated message, this is -1.
|
||||
MessageID int
|
||||
// The command that the client wants from the server.
|
||||
// When sent from the server, the literal string 'True' indicates success.
|
||||
// Before sending, a blank Command will be converted into SuccessCommand.
|
||||
Command Command
|
||||
// Result of json.Unmarshal on the third field send from the client
|
||||
Arguments interface{}
|
||||
|
||||
origArguments string
|
||||
}
|
||||
|
||||
type AuthInfo struct {
|
||||
// The client's claimed username on Twitch.
|
||||
TwitchUsername string
|
||||
|
||||
// Whether or not the server has validated the client's claimed username.
|
||||
UsernameValidated bool
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
// The client ID.
|
||||
// This must be written once by the owning goroutine before the struct is passed off to any other goroutines.
|
||||
ClientID uuid.UUID
|
||||
|
||||
// The client's version.
|
||||
// This must be written once by the owning goroutine before the struct is passed off to any other goroutines.
|
||||
Version string
|
||||
|
||||
// This mutex protects writable data in this struct.
|
||||
// If it seems to be a performance problem, we can split this.
|
||||
Mutex sync.Mutex
|
||||
|
||||
// TODO(riking) - does this need to be protected cross-thread?
|
||||
AuthInfo
|
||||
|
||||
// Username validation nonce.
|
||||
ValidationNonce string
|
||||
|
||||
// The list of chats this client is currently in.
|
||||
// Protected by Mutex.
|
||||
CurrentChannels []string
|
||||
|
||||
// List of channels that we have not yet checked current chat-related channel info for.
|
||||
// This lets us batch the backlog requests.
|
||||
// Protected by Mutex.
|
||||
PendingSubscriptionsBacklog []string
|
||||
|
||||
// A timer that, when fired, will make the pending backlog requests.
|
||||
// Usually nil. Protected by Mutex.
|
||||
MakePendingRequests *time.Timer
|
||||
|
||||
// Server-initiated messages should be sent here
|
||||
// Never nil.
|
||||
MessageChannel chan<- ClientMessage
|
||||
}
|
||||
|
||||
type tgmarray []TimestampedGlobalMessage
|
||||
type tmmarray []TimestampedMultichatMessage
|
||||
|
||||
func (ta tgmarray) Len() int {
|
||||
return len(ta)
|
||||
}
|
||||
func (ta tgmarray) Less(i, j int) bool {
|
||||
return ta[i].Timestamp.Before(ta[j].Timestamp)
|
||||
}
|
||||
func (ta tgmarray) Swap(i, j int) {
|
||||
ta[i], ta[j] = ta[j], ta[i]
|
||||
}
|
||||
func (ta tgmarray) GetTime(i int) time.Time {
|
||||
return ta[i].Timestamp
|
||||
}
|
||||
func (ta tmmarray) Len() int {
|
||||
return len(ta)
|
||||
}
|
||||
func (ta tmmarray) Less(i, j int) bool {
|
||||
return ta[i].Timestamp.Before(ta[j].Timestamp)
|
||||
}
|
||||
func (ta tmmarray) Swap(i, j int) {
|
||||
ta[i], ta[j] = ta[j], ta[i]
|
||||
}
|
||||
func (ta tmmarray) GetTime(i int) time.Time {
|
||||
return ta[i].Timestamp
|
||||
}
|
||||
|
||||
func (bct BacklogCacheType) Name() string {
|
||||
switch bct {
|
||||
case CacheTypeInvalid:
|
||||
return ""
|
||||
case CacheTypeNever:
|
||||
return "never"
|
||||
case CacheTypeTimestamps:
|
||||
return "timed"
|
||||
case CacheTypeLastOnly:
|
||||
return "last"
|
||||
case CacheTypePersistent:
|
||||
return "persist"
|
||||
}
|
||||
panic("Invalid BacklogCacheType value")
|
||||
}
|
||||
|
||||
var CacheTypesByName = map[string]BacklogCacheType{
|
||||
"never": CacheTypeNever,
|
||||
"timed": CacheTypeTimestamps,
|
||||
"last": CacheTypeLastOnly,
|
||||
"persist": CacheTypePersistent,
|
||||
}
|
||||
|
||||
func BacklogCacheTypeByName(name string) (bct BacklogCacheType) {
|
||||
// CacheTypeInvalid is the zero value so it doesn't matter
|
||||
bct, _ = CacheTypesByName[name]
|
||||
return
|
||||
}
|
||||
|
||||
// Implements Stringer
|
||||
func (bct BacklogCacheType) String() string { return bct.Name() }
|
||||
|
||||
// Implements json.Marshaler
|
||||
func (bct BacklogCacheType) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(bct.Name())
|
||||
}
|
||||
|
||||
// Implements json.Unmarshaler
|
||||
func (pbct *BacklogCacheType) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
err := json.Unmarshal(data, &str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if str == "" {
|
||||
*pbct = CacheTypeInvalid
|
||||
return nil
|
||||
}
|
||||
val := BacklogCacheTypeByName(str)
|
||||
if val != CacheTypeInvalid {
|
||||
*pbct = val
|
||||
return nil
|
||||
}
|
||||
return ErrorUnrecognizedCacheType
|
||||
}
|
||||
|
||||
func (mtt MessageTargetType) Name() string {
|
||||
switch mtt {
|
||||
case MsgTargetTypeInvalid:
|
||||
return ""
|
||||
case MsgTargetTypeSingle:
|
||||
return "single"
|
||||
case MsgTargetTypeChat:
|
||||
return "chat"
|
||||
case MsgTargetTypeMultichat:
|
||||
return "multichat"
|
||||
case MsgTargetTypeGlobal:
|
||||
return "global"
|
||||
}
|
||||
panic("Invalid MessageTargetType value")
|
||||
}
|
||||
|
||||
var TargetTypesByName = map[string]MessageTargetType{
|
||||
"single": MsgTargetTypeSingle,
|
||||
"chat": MsgTargetTypeChat,
|
||||
"multichat": MsgTargetTypeMultichat,
|
||||
"global": MsgTargetTypeGlobal,
|
||||
}
|
||||
|
||||
func MessageTargetTypeByName(name string) (mtt MessageTargetType) {
|
||||
// MsgTargetTypeInvalid is the zero value so it doesn't matter
|
||||
mtt, _ = TargetTypesByName[name]
|
||||
return
|
||||
}
|
||||
|
||||
// Implements Stringer
|
||||
func (mtt MessageTargetType) String() string { return mtt.Name() }
|
||||
|
||||
// Implements json.Marshaler
|
||||
func (mtt MessageTargetType) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(mtt.Name())
|
||||
}
|
||||
|
||||
// Implements json.Unmarshaler
|
||||
func (pmtt *MessageTargetType) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
err := json.Unmarshal(data, &str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if str == "" {
|
||||
*pmtt = MsgTargetTypeInvalid
|
||||
return nil
|
||||
}
|
||||
mtt := MessageTargetTypeByName(str)
|
||||
if mtt != MsgTargetTypeInvalid {
|
||||
*pmtt = mtt
|
||||
return nil
|
||||
}
|
||||
return ErrorUnrecognizedTargetType
|
||||
}
|
301
socketserver/server/backend.go
Normal file
301
socketserver/server/backend.go
Normal file
|
@ -0,0 +1,301 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sync"
|
||||
|
||||
"github.com/pmylund/go-cache"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
const bPathAnnounceStartup = "/startup"
|
||||
const bPathAddTopic = "/topics"
|
||||
const bPathAggStats = "/stats"
|
||||
const bPathOtherCommand = "/cmd/"
|
||||
|
||||
type backendInfo struct {
|
||||
HTTPClient http.Client
|
||||
baseURL string
|
||||
responseCache *cache.Cache
|
||||
|
||||
postStatisticsURL string
|
||||
addTopicURL string
|
||||
announceStartupURL string
|
||||
|
||||
sharedKey [32]byte
|
||||
serverID int
|
||||
|
||||
lastSuccess map[string]time.Time
|
||||
lastSuccessLock sync.Mutex
|
||||
}
|
||||
|
||||
var Backend *backendInfo
|
||||
|
||||
func setupBackend(config *ConfigFile) *backendInfo {
|
||||
b := new(backendInfo)
|
||||
Backend = b
|
||||
b.serverID = config.ServerID
|
||||
|
||||
b.HTTPClient.Timeout = 60 * time.Second
|
||||
b.baseURL = config.BackendURL
|
||||
b.responseCache = cache.New(60*time.Second, 120*time.Second)
|
||||
|
||||
b.announceStartupURL = fmt.Sprintf("%s%s", b.baseURL, bPathAnnounceStartup)
|
||||
b.addTopicURL = fmt.Sprintf("%s%s", b.baseURL, bPathAddTopic)
|
||||
b.postStatisticsURL = fmt.Sprintf("%s%s", b.baseURL, bPathAggStats)
|
||||
|
||||
epochTime := time.Unix(0, 0).UTC()
|
||||
lastBackendSuccess := map[string]time.Time{
|
||||
bPathAnnounceStartup: epochTime,
|
||||
bPathAddTopic: epochTime,
|
||||
bPathAggStats: epochTime,
|
||||
bPathOtherCommand: epochTime,
|
||||
}
|
||||
b.lastSuccess = lastBackendSuccess
|
||||
|
||||
var theirPublic, ourPrivate [32]byte
|
||||
copy(theirPublic[:], config.BackendPublicKey)
|
||||
copy(ourPrivate[:], config.OurPrivateKey)
|
||||
|
||||
box.Precompute(&b.sharedKey, &theirPublic, &ourPrivate)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func getCacheKey(remoteCommand, data string) string {
|
||||
return fmt.Sprintf("%s/%s", remoteCommand, data)
|
||||
}
|
||||
|
||||
// ErrForwardedFromBackend is an error returned by the backend server.
|
||||
type ErrForwardedFromBackend struct {
|
||||
JSONError interface{}
|
||||
}
|
||||
|
||||
func (bfe ErrForwardedFromBackend) Error() string {
|
||||
bytes, _ := json.Marshal(bfe.JSONError)
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// ErrAuthorizationNeeded is emitted when the backend replies with HTTP 401.
|
||||
// Indicates that an attempt to validate `ClientInfo.TwitchUsername` should be attempted.
|
||||
var ErrAuthorizationNeeded = errors.New("Must authenticate Twitch username to use this command")
|
||||
|
||||
// SendRemoteCommandCached performs a RPC call on the backend, but caches responses.
|
||||
func (backend *backendInfo) SendRemoteCommandCached(remoteCommand, data string, auth AuthInfo) (string, error) {
|
||||
cached, ok := backend.responseCache.Get(getCacheKey(remoteCommand, data))
|
||||
if ok {
|
||||
return cached.(string), nil
|
||||
}
|
||||
return backend.SendRemoteCommand(remoteCommand, data, auth)
|
||||
}
|
||||
|
||||
// SendRemoteCommand performs a RPC call on the backend by POSTing to `/cmd/$remoteCommand`.
|
||||
// The form data is as follows: `clientData` is the JSON in the `data` parameter
|
||||
// (should be retrieved from ClientMessage.Arguments), and either `username` or
|
||||
// `usernameClaimed` depending on whether AuthInfo.UsernameValidates is true is AuthInfo.TwitchUsername.
|
||||
func (backend *backendInfo) SendRemoteCommand(remoteCommand, data string, auth AuthInfo) (responseStr string, err error) {
|
||||
destURL := fmt.Sprintf("%s/cmd/%s", backend.baseURL, remoteCommand)
|
||||
healthBucket := fmt.Sprintf("/cmd/%s", remoteCommand)
|
||||
|
||||
formData := url.Values{
|
||||
"clientData": []string{data},
|
||||
"username": []string{auth.TwitchUsername},
|
||||
}
|
||||
|
||||
if auth.UsernameValidated {
|
||||
formData.Set("authenticated", "1")
|
||||
} else {
|
||||
formData.Set("authenticated", "0")
|
||||
}
|
||||
|
||||
sealedForm, err := backend.SealRequest(formData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := backend.HTTPClient.PostForm(destURL, sealedForm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
responseStr = string(respBytes)
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return "", ErrAuthorizationNeeded
|
||||
} else if resp.StatusCode != 200 {
|
||||
if resp.Header.Get("Content-Type") == "application/json" {
|
||||
var err2 ErrForwardedFromBackend
|
||||
err := json.Unmarshal(respBytes, &err2.JSONError)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error decoding json error from backend: %v | %s", err, responseStr)
|
||||
}
|
||||
return "", err2
|
||||
}
|
||||
return "", httpError(resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.Header.Get("FFZ-Cache") != "" {
|
||||
durSecs, err := strconv.ParseInt(resp.Header.Get("FFZ-Cache"), 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("The RPC server returned a non-integer cache duration: %v", err)
|
||||
}
|
||||
duration := time.Duration(durSecs) * time.Second
|
||||
backend.responseCache.Set(getCacheKey(remoteCommand, data), responseStr, duration)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
backend.lastSuccessLock.Lock()
|
||||
defer backend.lastSuccessLock.Unlock()
|
||||
backend.lastSuccess[bPathOtherCommand] = now
|
||||
backend.lastSuccess[healthBucket] = now
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// SendAggregatedData sends aggregated emote usage and following data to the backend server.
|
||||
func (backend *backendInfo) SendAggregatedData(form url.Values) error {
|
||||
sealedForm, err := backend.SealRequest(form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := backend.HTTPClient.PostForm(backend.postStatisticsURL, sealedForm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
resp.Body.Close()
|
||||
return httpError(resp.StatusCode)
|
||||
}
|
||||
|
||||
backend.lastSuccessLock.Lock()
|
||||
defer backend.lastSuccessLock.Unlock()
|
||||
backend.lastSuccess[bPathAggStats] = time.Now().UTC()
|
||||
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// ErrBackendNotOK indicates that the backend replied with something other than the string "ok".
|
||||
type ErrBackendNotOK struct {
|
||||
Response string
|
||||
Code int
|
||||
}
|
||||
|
||||
// Error Implements the error interface.
|
||||
func (noe ErrBackendNotOK) Error() string {
|
||||
return fmt.Sprintf("backend returned %d: %s", noe.Code, noe.Response)
|
||||
}
|
||||
|
||||
// SendNewTopicNotice notifies the backend that a client has performed the first subscription to a pub/sub topic.
|
||||
// POST data:
|
||||
// channels=room.trihex
|
||||
// added=t
|
||||
func (backend *backendInfo) SendNewTopicNotice(topic string) error {
|
||||
return backend.sendTopicNotice(topic, true)
|
||||
}
|
||||
|
||||
// SendCleanupTopicsNotice notifies the backend that pub/sub topics have no subscribers anymore.
|
||||
// POST data:
|
||||
// channels=room.sirstendec,room.bobross,feature.foo
|
||||
// added=f
|
||||
func (backend *backendInfo) SendCleanupTopicsNotice(topics []string) error {
|
||||
return backend.sendTopicNotice(strings.Join(topics, ","), false)
|
||||
}
|
||||
|
||||
func (backend *backendInfo) sendTopicNotice(topic string, added bool) error {
|
||||
formData := url.Values{}
|
||||
formData.Set("channels", topic)
|
||||
if added {
|
||||
formData.Set("added", "t")
|
||||
} else {
|
||||
formData.Set("added", "f")
|
||||
}
|
||||
|
||||
sealedForm, err := backend.SealRequest(formData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := backend.HTTPClient.PostForm(backend.addTopicURL, sealedForm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ErrBackendNotOK{Code: resp.StatusCode, Response: fmt.Sprintf("(error reading non-2xx response): %s", err.Error())}
|
||||
}
|
||||
return ErrBackendNotOK{Code: resp.StatusCode, Response: string(respBytes)}
|
||||
}
|
||||
|
||||
backend.lastSuccessLock.Lock()
|
||||
defer backend.lastSuccessLock.Unlock()
|
||||
backend.lastSuccess[bPathAddTopic] = time.Now().UTC()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpError(statusCode int) error {
|
||||
return fmt.Errorf("backend http error: %d", statusCode)
|
||||
}
|
||||
|
||||
// GenerateKeys generates a new NaCl keypair for the server and writes out the default configuration file.
|
||||
func GenerateKeys(outputFile, serverID, theirPublicStr string) {
|
||||
var err error
|
||||
output := ConfigFile{
|
||||
ListenAddr: "0.0.0.0:8001",
|
||||
SSLListenAddr: "0.0.0.0:443",
|
||||
BackendURL: "http://localhost:8002/ffz",
|
||||
MinMemoryKBytes: defaultMinMemoryKB,
|
||||
}
|
||||
|
||||
output.ServerID, err = strconv.Atoi(serverID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ourPublic, ourPrivate, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
output.OurPublicKey, output.OurPrivateKey = ourPublic[:], ourPrivate[:]
|
||||
|
||||
if theirPublicStr != "" {
|
||||
reader := base64.NewDecoder(base64.StdEncoding, strings.NewReader(theirPublicStr))
|
||||
theirPublic, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
output.BackendPublicKey = theirPublic
|
||||
}
|
||||
|
||||
bytes, err := json.MarshalIndent(output, "", "\t")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(string(bytes))
|
||||
err = ioutil.WriteFile(outputFile, bytes, 0600)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
131
socketserver/server/backend_test.go
Normal file
131
socketserver/server/backend_test.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
. "gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) { TestingT(t) }
|
||||
|
||||
func TestSealRequest(t *testing.T) {
|
||||
TSetup(SetupNoServers, nil)
|
||||
b := Backend
|
||||
|
||||
values := url.Values{
|
||||
"QuickBrownFox": []string{"LazyDog"},
|
||||
}
|
||||
|
||||
sealedValues, err := b.SealRequest(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// sealedValues.Encode()
|
||||
// id=0&msg=KKtbng49dOLLyjeuX5AnXiEe6P0uZwgeP_7mMB5vhP-wMAAPZw%3D%3D&nonce=-wRbUnifscisWUvhm3gBEXHN5QzrfzgV
|
||||
|
||||
unsealedValues, err := b.UnsealRequest(sealedValues)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if unsealedValues.Get("QuickBrownFox") != "LazyDog" {
|
||||
t.Errorf("Failed to round-trip, got back %v", unsealedValues)
|
||||
}
|
||||
}
|
||||
|
||||
type BackendSuite struct{}
|
||||
|
||||
var _ = Suite(&BackendSuite{})
|
||||
|
||||
func (s *BackendSuite) TestSendRemoteCommand(c *C) {
|
||||
const TestCommand1 = "somecommand"
|
||||
const TestCommand2 = "other"
|
||||
const PathTestCommand1 = "/cmd/" + TestCommand1
|
||||
const PathTestCommand2 = "/cmd/" + TestCommand2
|
||||
const TestData1 = "623478.32"
|
||||
const TestData2 = "\"Hello, there\""
|
||||
const TestData3 = "3"
|
||||
const TestUsername = "sirstendec"
|
||||
const TestResponse1 = "asfdg"
|
||||
const TestResponse2 = "yuiop"
|
||||
const TestErrorText = "{\"err\":\"some kind of special error\"}"
|
||||
|
||||
var AnonAuthInfo = AuthInfo{}
|
||||
var NonValidatedAuthInfo = AuthInfo{TwitchUsername: TestUsername}
|
||||
var ValidatedAuthInfo = AuthInfo{TwitchUsername: TestUsername, UsernameValidated: true}
|
||||
|
||||
headersCacheTwoSeconds := http.Header{"FFZ-Cache": []string{"2"}}
|
||||
headersCacheInvalid := http.Header{"FFZ-Cache": []string{"NotANumber"}}
|
||||
headersApplicationJson := http.Header{"Content-Type": []string{"application/json"}}
|
||||
|
||||
mockBackend := NewTBackendRequestChecker(c,
|
||||
TExpectedBackendRequest{200, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{""}}, TestResponse1, nil},
|
||||
TExpectedBackendRequest{200, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{""}}, TestResponse2, nil},
|
||||
TExpectedBackendRequest{200, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, TestResponse1, nil},
|
||||
TExpectedBackendRequest{200, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"1"}, "username": []string{TestUsername}}, TestResponse1, nil},
|
||||
TExpectedBackendRequest{200, PathTestCommand2, &url.Values{"clientData": []string{TestData2}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, TestResponse1, headersCacheTwoSeconds},
|
||||
// cached
|
||||
// cached
|
||||
TExpectedBackendRequest{200, PathTestCommand2, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, TestResponse2, headersCacheTwoSeconds},
|
||||
TExpectedBackendRequest{401, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, "", nil},
|
||||
TExpectedBackendRequest{503, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, "", nil},
|
||||
TExpectedBackendRequest{418, PathTestCommand1, &url.Values{"clientData": []string{TestData1}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, TestErrorText, headersApplicationJson},
|
||||
TExpectedBackendRequest{200, PathTestCommand2, &url.Values{"clientData": []string{TestData3}, "authenticated": []string{"0"}, "username": []string{TestUsername}}, TestResponse1, headersCacheInvalid},
|
||||
)
|
||||
_, _, _ = TSetup(SetupWantBackendServer, mockBackend)
|
||||
defer mockBackend.Close()
|
||||
|
||||
var resp string
|
||||
var err error
|
||||
b := Backend
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, AnonAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, AnonAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse2)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, ValidatedAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
// cache save
|
||||
resp, err = b.SendRemoteCommandCached(TestCommand2, TestData2, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommandCached(TestCommand2, TestData2, NonValidatedAuthInfo) // cache hit
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommandCached(TestCommand2, TestData2, AnonAuthInfo) // cache hit
|
||||
c.Check(resp, Equals, TestResponse1)
|
||||
c.Check(err, IsNil)
|
||||
// cache miss - data is different
|
||||
resp, err = b.SendRemoteCommandCached(TestCommand2, TestData1, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, TestResponse2)
|
||||
c.Check(err, IsNil)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, "")
|
||||
c.Check(err, Equals, ErrAuthorizationNeeded)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, "")
|
||||
c.Check(err, ErrorMatches, "backend http error: 503")
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand1, TestData1, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, "")
|
||||
c.Check(err, ErrorMatches, TestErrorText)
|
||||
|
||||
resp, err = b.SendRemoteCommand(TestCommand2, TestData3, NonValidatedAuthInfo)
|
||||
c.Check(resp, Equals, "")
|
||||
c.Check(err, ErrorMatches, "The RPC server returned a non-integer cache duration: .*")
|
||||
}
|
606
socketserver/server/commands.go
Normal file
606
socketserver/server/commands.go
Normal file
|
@ -0,0 +1,606 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// Command is a string indicating which RPC is requested.
|
||||
// The Commands sent from Client -> Server and Server -> Client are disjoint sets.
|
||||
type Command string
|
||||
|
||||
// CommandHandler is a RPC handler associated with a Command.
|
||||
type CommandHandler func(*websocket.Conn, *ClientInfo, ClientMessage) (ClientMessage, error)
|
||||
|
||||
var commandHandlers = map[Command]CommandHandler{
|
||||
HelloCommand: C2SHello,
|
||||
"ping": C2SPing,
|
||||
SetUserCommand: C2SSetUser,
|
||||
ReadyCommand: C2SReady,
|
||||
|
||||
"sub": C2SSubscribe,
|
||||
"unsub": C2SUnsubscribe,
|
||||
|
||||
"track_follow": C2STrackFollow,
|
||||
"emoticon_uses": C2SEmoticonUses,
|
||||
"survey": C2SSurvey,
|
||||
|
||||
"twitch_emote": C2SHandleBunchedCommand,
|
||||
"get_link": C2SHandleBunchedCommand,
|
||||
"get_display_name": C2SHandleBunchedCommand,
|
||||
"update_follow_buttons": C2SHandleRemoteCommand,
|
||||
"chat_history": C2SHandleRemoteCommand,
|
||||
"user_history": C2SHandleRemoteCommand,
|
||||
}
|
||||
|
||||
func setupInterning() {
|
||||
PubSubChannelPool = NewStringPool()
|
||||
TwitchChannelPool = NewStringPool()
|
||||
|
||||
CommandPool = NewStringPool()
|
||||
CommandPool._Intern_Setup(string(HelloCommand))
|
||||
CommandPool._Intern_Setup("ping")
|
||||
CommandPool._Intern_Setup(string(SetUserCommand))
|
||||
CommandPool._Intern_Setup(string(ReadyCommand))
|
||||
CommandPool._Intern_Setup("sub")
|
||||
CommandPool._Intern_Setup("unsub")
|
||||
CommandPool._Intern_Setup("track_follow")
|
||||
CommandPool._Intern_Setup("emoticon_uses")
|
||||
CommandPool._Intern_Setup("twitch_emote")
|
||||
CommandPool._Intern_Setup("get_link")
|
||||
CommandPool._Intern_Setup("get_display_name")
|
||||
CommandPool._Intern_Setup("update_follow_buttons")
|
||||
CommandPool._Intern_Setup("chat_history")
|
||||
CommandPool._Intern_Setup("user_history")
|
||||
CommandPool._Intern_Setup("adjacent_history")
|
||||
}
|
||||
|
||||
// DispatchC2SCommand handles a C2S Command in the provided ClientMessage.
|
||||
// It calls the correct CommandHandler function, catching panics.
|
||||
// It sends either the returned Reply ClientMessage, setting the correct messageID, or sends an ErrorCommand
|
||||
func DispatchC2SCommand(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) {
|
||||
handler, ok := commandHandlers[msg.Command]
|
||||
if !ok {
|
||||
handler = C2SHandleRemoteCommand
|
||||
}
|
||||
|
||||
CommandCounter <- msg.Command
|
||||
|
||||
response, err := callHandler(handler, conn, client, msg)
|
||||
|
||||
if err == nil {
|
||||
if response.Command == AsyncResponseCommand {
|
||||
// Don't send anything
|
||||
// The response will be delivered over client.MessageChannel / serverMessageChan
|
||||
} else {
|
||||
response.MessageID = msg.MessageID
|
||||
SendMessage(conn, response)
|
||||
}
|
||||
} else {
|
||||
SendMessage(conn, ClientMessage{
|
||||
MessageID: msg.MessageID,
|
||||
Command: ErrorCommand,
|
||||
Arguments: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func callHandler(handler CommandHandler, conn *websocket.Conn, client *ClientInfo, cmsg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
var ok bool
|
||||
fmt.Print("[!] Error executing command", cmsg.Command, "--", r)
|
||||
err, ok = r.(error)
|
||||
if !ok {
|
||||
err = fmt.Errorf("command handler: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return handler(conn, client, cmsg)
|
||||
}
|
||||
|
||||
// C2SHello implements the `hello` C2S Command.
|
||||
// It calls SubscribeGlobal() and SubscribeDefaults() with the client, and fills out ClientInfo.Version and ClientInfo.ClientID.
|
||||
func C2SHello(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
ary, ok := msg.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
if len(ary) != 2 {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
version, ok := ary[0].(string)
|
||||
if !ok {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
|
||||
client.VersionString = copyString(version)
|
||||
client.Version = VersionFromString(version)
|
||||
|
||||
if clientIDStr, ok := ary[1].(string); ok {
|
||||
client.ClientID = uuid.FromStringOrNil(clientIDStr)
|
||||
if client.ClientID == uuid.Nil {
|
||||
client.ClientID = uuid.NewV4()
|
||||
}
|
||||
} else if _, ok := ary[1].(bool); ok {
|
||||
// opt out
|
||||
client.ClientID = AnonymousClientID
|
||||
} else {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
|
||||
uniqueUserChannel <- client.ClientID
|
||||
|
||||
SubscribeGlobal(client)
|
||||
SubscribeDefaults(client)
|
||||
|
||||
jsTime := float64(time.Now().UnixNano()/1000) / 1000
|
||||
return ClientMessage{
|
||||
Arguments: []interface{}{
|
||||
client.ClientID.String(),
|
||||
jsTime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func C2SPing(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
return ClientMessage{
|
||||
Arguments: float64(time.Now().UnixNano()/1000) / 1000,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func C2SSetUser(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
username, err := msg.ArgumentsAsString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
username = copyString(username)
|
||||
|
||||
client.Mutex.Lock()
|
||||
client.UsernameValidated = false
|
||||
client.TwitchUsername = username
|
||||
client.Mutex.Unlock()
|
||||
|
||||
if Configuration.SendAuthToNewClients {
|
||||
client.MsgChannelKeepalive.Add(1)
|
||||
go client.StartAuthorization(func(_ *ClientInfo, _ bool) {
|
||||
client.MsgChannelKeepalive.Done()
|
||||
})
|
||||
}
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
func C2SReady(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
// disconnectAt, err := msg.ArgumentsAsInt()
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
|
||||
client.Mutex.Lock()
|
||||
client.ReadyComplete = true
|
||||
client.Mutex.Unlock()
|
||||
|
||||
client.MsgChannelKeepalive.Add(1)
|
||||
go func() {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: SuccessCommand}
|
||||
SendBacklogForNewClient(client)
|
||||
client.MsgChannelKeepalive.Done()
|
||||
}()
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
||||
|
||||
func C2SSubscribe(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, err := msg.ArgumentsAsString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
channel = PubSubChannelPool.Intern(channel)
|
||||
|
||||
client.Mutex.Lock()
|
||||
AddToSliceS(&client.CurrentChannels, channel)
|
||||
client.Mutex.Unlock()
|
||||
|
||||
SubscribeChannel(client, channel)
|
||||
|
||||
if client.ReadyComplete {
|
||||
client.MsgChannelKeepalive.Add(1)
|
||||
go func() {
|
||||
SendBacklogForChannel(client, channel)
|
||||
client.MsgChannelKeepalive.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
// C2SUnsubscribe implements the `unsub` C2S Command.
|
||||
// It removes the channel from ClientInfo.CurrentChannels and calls UnsubscribeSingleChat.
|
||||
func C2SUnsubscribe(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, err := msg.ArgumentsAsString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
channel = PubSubChannelPool.Intern(channel)
|
||||
|
||||
client.Mutex.Lock()
|
||||
RemoveFromSliceS(&client.CurrentChannels, channel)
|
||||
client.Mutex.Unlock()
|
||||
|
||||
UnsubscribeSingleChat(client, channel)
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
// C2SSurvey implements the survey C2S Command.
|
||||
// Surveys are discarded.s
|
||||
func C2SSurvey(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
// Discard
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
type followEvent struct {
|
||||
User string `json:"u"`
|
||||
Channel string `json:"c"`
|
||||
NowFollowing bool `json:"f"`
|
||||
Timestamp time.Time `json:"t"`
|
||||
}
|
||||
|
||||
var followEvents []followEvent
|
||||
|
||||
// followEventsLock is the lock for followEvents.
|
||||
var followEventsLock sync.Mutex
|
||||
|
||||
// C2STrackFollow implements the `track_follow` C2S Command.
|
||||
// It adds the record to `followEvents`, which is submitted to the backend on a timer.
|
||||
func C2STrackFollow(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
channel, following, err := msg.ArgumentsAsStringAndBool()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
channel = TwitchChannelPool.Intern(channel)
|
||||
|
||||
followEventsLock.Lock()
|
||||
followEvents = append(followEvents, followEvent{User: client.TwitchUsername, Channel: channel, NowFollowing: following, Timestamp: now})
|
||||
followEventsLock.Unlock()
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
// AggregateEmoteUsage is a map from emoteID to a map from chatroom name to usage count.
|
||||
var aggregateEmoteUsage = make(map[int]map[string]int)
|
||||
|
||||
// AggregateEmoteUsageLock is the lock for AggregateEmoteUsage.
|
||||
var aggregateEmoteUsageLock sync.Mutex
|
||||
|
||||
// ErrNegativeEmoteUsage is emitted when the submitted emote usage is negative.
|
||||
var ErrNegativeEmoteUsage = errors.New("Emote usage count cannot be negative")
|
||||
|
||||
// C2SEmoticonUses implements the `emoticon_uses` C2S Command.
|
||||
// msg.Arguments are in the JSON format of [1]map[emoteID]map[ChatroomName]float64.
|
||||
func C2SEmoticonUses(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
// if this panics, will be caught by callHandler
|
||||
mapRoot := msg.Arguments.([]interface{})[0].(map[string]interface{})
|
||||
|
||||
// Validate: male suire
|
||||
for strEmote, val1 := range mapRoot {
|
||||
_, err = strconv.Atoi(strEmote)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mapInner := val1.(map[string]interface{})
|
||||
for _, val2 := range mapInner {
|
||||
var count = int(val2.(float64))
|
||||
if count <= 0 {
|
||||
err = ErrNegativeEmoteUsage
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggregateEmoteUsageLock.Lock()
|
||||
defer aggregateEmoteUsageLock.Unlock()
|
||||
|
||||
var total int
|
||||
|
||||
for strEmote, val1 := range mapRoot {
|
||||
var emoteID int
|
||||
emoteID, err = strconv.Atoi(strEmote)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
destMapInner, ok := aggregateEmoteUsage[emoteID]
|
||||
if !ok {
|
||||
destMapInner = make(map[string]int)
|
||||
aggregateEmoteUsage[emoteID] = destMapInner
|
||||
}
|
||||
|
||||
mapInner := val1.(map[string]interface{})
|
||||
for roomName, val2 := range mapInner {
|
||||
var count = int(val2.(float64))
|
||||
if count > 200 {
|
||||
count = 200
|
||||
}
|
||||
roomName = TwitchChannelPool.Intern(roomName)
|
||||
destMapInner[roomName] += count
|
||||
total += count
|
||||
}
|
||||
}
|
||||
|
||||
Statistics.EmotesReportedTotal += uint64(total)
|
||||
|
||||
return ResponseSuccess, nil
|
||||
}
|
||||
|
||||
// is_init_func
|
||||
func aggregateDataSender() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
aggregateDataSender_do()
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateDataSender_do() {
|
||||
followEventsLock.Lock()
|
||||
follows := followEvents
|
||||
followEvents = nil
|
||||
followEventsLock.Unlock()
|
||||
aggregateEmoteUsageLock.Lock()
|
||||
emoteUsage := aggregateEmoteUsage
|
||||
aggregateEmoteUsage = make(map[int]map[string]int)
|
||||
aggregateEmoteUsageLock.Unlock()
|
||||
|
||||
reportForm := url.Values{}
|
||||
|
||||
followJSON, err := json.Marshal(follows)
|
||||
if err != nil {
|
||||
log.Println("error reporting aggregate data:", err)
|
||||
} else {
|
||||
reportForm.Set("follows", string(followJSON))
|
||||
}
|
||||
|
||||
strEmoteUsage := make(map[string]map[string]int)
|
||||
for emoteID, usageByChannel := range emoteUsage {
|
||||
strEmoteID := strconv.Itoa(emoteID)
|
||||
strEmoteUsage[strEmoteID] = usageByChannel
|
||||
}
|
||||
emoteJSON, err := json.Marshal(strEmoteUsage)
|
||||
if err != nil {
|
||||
log.Println("error reporting aggregate data:", err)
|
||||
} else {
|
||||
reportForm.Set("emotes", string(emoteJSON))
|
||||
}
|
||||
|
||||
err = Backend.SendAggregatedData(reportForm)
|
||||
if err != nil {
|
||||
log.Println("error reporting aggregate data:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// done
|
||||
}
|
||||
|
||||
type bunchedRequest struct {
|
||||
Command Command
|
||||
Param string
|
||||
}
|
||||
|
||||
type cachedBunchedResponse struct {
|
||||
Response string
|
||||
Timestamp time.Time
|
||||
}
|
||||
type bunchSubscriber struct {
|
||||
Client *ClientInfo
|
||||
MessageID int
|
||||
}
|
||||
|
||||
type bunchSubscriberList struct {
|
||||
sync.Mutex
|
||||
Members []bunchSubscriber
|
||||
}
|
||||
|
||||
type cacheStatus byte
|
||||
|
||||
const (
|
||||
CacheStatusNotFound = iota
|
||||
CacheStatusFound
|
||||
CacheStatusExpired
|
||||
)
|
||||
|
||||
var pendingBunchedRequests = make(map[bunchedRequest]*bunchSubscriberList)
|
||||
var pendingBunchLock sync.Mutex
|
||||
var bunchCache = make(map[bunchedRequest]cachedBunchedResponse)
|
||||
var bunchCacheLock sync.RWMutex
|
||||
var bunchCacheCleanupSignal = sync.NewCond(&bunchCacheLock)
|
||||
var bunchCacheLastCleanup time.Time
|
||||
|
||||
func bunchedRequestFromCM(msg *ClientMessage) bunchedRequest {
|
||||
return bunchedRequest{Command: msg.Command, Param: copyString(msg.origArguments)}
|
||||
}
|
||||
|
||||
// is_init_func
|
||||
func bunchCacheJanitor() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(30 * time.Minute)
|
||||
bunchCacheCleanupSignal.Signal()
|
||||
}
|
||||
}()
|
||||
|
||||
bunchCacheLock.Lock()
|
||||
for {
|
||||
// Unlocks CachedBunchLock, waits for signal, re-locks
|
||||
bunchCacheCleanupSignal.Wait()
|
||||
|
||||
if bunchCacheLastCleanup.After(time.Now().Add(-1 * time.Second)) {
|
||||
// skip if it's been less than 1 second
|
||||
continue
|
||||
}
|
||||
|
||||
// CachedBunchLock is held here
|
||||
keepIfAfter := time.Now().Add(-5 * time.Minute)
|
||||
for req, resp := range bunchCache {
|
||||
if !resp.Timestamp.After(keepIfAfter) {
|
||||
delete(bunchCache, req)
|
||||
}
|
||||
}
|
||||
bunchCacheLastCleanup = time.Now()
|
||||
// Loop and Wait(), which re-locks
|
||||
}
|
||||
}
|
||||
|
||||
var emptyCachedBunchedResponse cachedBunchedResponse
|
||||
|
||||
func bunchGetCacheStatus(br bunchedRequest, client *ClientInfo) (cacheStatus, cachedBunchedResponse) {
|
||||
bunchCacheLock.RLock()
|
||||
defer bunchCacheLock.RUnlock()
|
||||
cachedResponse, ok := bunchCache[br]
|
||||
if ok && cachedResponse.Timestamp.After(time.Now().Add(-5*time.Minute)) {
|
||||
return CacheStatusFound, cachedResponse
|
||||
} else if ok {
|
||||
return CacheStatusExpired, emptyCachedBunchedResponse
|
||||
}
|
||||
return CacheStatusNotFound, emptyCachedBunchedResponse
|
||||
}
|
||||
|
||||
func normalizeBunchedRequest(br bunchedRequest) bunchedRequest {
|
||||
if br.Command == "get_link" {
|
||||
// TODO
|
||||
}
|
||||
return br
|
||||
}
|
||||
|
||||
// C2SHandleBunchedCommand handles C2S Commands such as `get_link`.
|
||||
// It makes a request to the backend server for the data, but any other requests coming in while the first is pending also get the responses from the first one.
|
||||
// Additionally, results are cached.
|
||||
func C2SHandleBunchedCommand(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
// FIXME(riking): Function is too complex
|
||||
|
||||
br := bunchedRequestFromCM(&msg)
|
||||
br = normalizeBunchedRequest(br)
|
||||
|
||||
cacheStatus, cachedResponse := bunchGetCacheStatus(br, client)
|
||||
|
||||
if cacheStatus == CacheStatusFound {
|
||||
var response ClientMessage
|
||||
response.Command = SuccessCommand
|
||||
response.MessageID = msg.MessageID
|
||||
response.origArguments = cachedResponse.Response
|
||||
response.parseOrigArguments()
|
||||
|
||||
return response, nil
|
||||
} else if cacheStatus == CacheStatusExpired {
|
||||
// Wake up the lazy janitor
|
||||
bunchCacheCleanupSignal.Signal()
|
||||
}
|
||||
|
||||
pendingBunchLock.Lock()
|
||||
defer pendingBunchLock.Unlock()
|
||||
list, ok := pendingBunchedRequests[br]
|
||||
if ok {
|
||||
list.Lock()
|
||||
AddToSliceB(&list.Members, client, msg.MessageID)
|
||||
list.Unlock()
|
||||
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
||||
|
||||
pendingBunchedRequests[br] = &bunchSubscriberList{Members: []bunchSubscriber{{Client: client, MessageID: msg.MessageID}}}
|
||||
|
||||
go func(request bunchedRequest) {
|
||||
respStr, err := Backend.SendRemoteCommandCached(string(request.Command), request.Param, AuthInfo{})
|
||||
|
||||
var msg ClientMessage
|
||||
if err == nil {
|
||||
msg.Command = SuccessCommand
|
||||
msg.origArguments = respStr
|
||||
msg.parseOrigArguments()
|
||||
} else {
|
||||
msg.Command = ErrorCommand
|
||||
msg.Arguments = err.Error()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
bunchCacheLock.Lock()
|
||||
bunchCache[request] = cachedBunchedResponse{Response: respStr, Timestamp: time.Now()}
|
||||
bunchCacheLock.Unlock()
|
||||
}
|
||||
|
||||
pendingBunchLock.Lock()
|
||||
bsl := pendingBunchedRequests[request]
|
||||
delete(pendingBunchedRequests, request)
|
||||
pendingBunchLock.Unlock()
|
||||
|
||||
bsl.Lock()
|
||||
for _, member := range bsl.Members {
|
||||
msg.MessageID = member.MessageID
|
||||
select {
|
||||
case member.Client.MessageChannel <- msg:
|
||||
case <-member.Client.MsgChannelIsDone:
|
||||
}
|
||||
}
|
||||
bsl.Unlock()
|
||||
}(br)
|
||||
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
||||
|
||||
func C2SHandleRemoteCommand(conn *websocket.Conn, client *ClientInfo, msg ClientMessage) (rmsg ClientMessage, err error) {
|
||||
client.MsgChannelKeepalive.Add(1)
|
||||
go doRemoteCommand(conn, msg, client)
|
||||
|
||||
return ClientMessage{Command: AsyncResponseCommand}, nil
|
||||
}
|
||||
|
||||
const AuthorizationFailedErrorString = "Failed to verify your Twitch username."
|
||||
const AuthorizationNeededError = "You must be signed in to use that command."
|
||||
|
||||
func doRemoteCommand(conn *websocket.Conn, msg ClientMessage, client *ClientInfo) {
|
||||
resp, err := Backend.SendRemoteCommandCached(string(msg.Command), copyString(msg.origArguments), client.AuthInfo)
|
||||
|
||||
if err == ErrAuthorizationNeeded {
|
||||
if client.TwitchUsername == "" {
|
||||
// Not logged in
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: ErrorCommand, Arguments: AuthorizationNeededError}
|
||||
client.MsgChannelKeepalive.Done()
|
||||
return
|
||||
}
|
||||
client.StartAuthorization(func(_ *ClientInfo, success bool) {
|
||||
if success {
|
||||
doRemoteCommand(conn, msg, client)
|
||||
} else {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: ErrorCommand, Arguments: AuthorizationFailedErrorString}
|
||||
client.MsgChannelKeepalive.Done()
|
||||
}
|
||||
})
|
||||
return // without keepalive.Done()
|
||||
} else if bfe, ok := err.(ErrForwardedFromBackend); ok {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: ErrorCommand, Arguments: bfe.JSONError}
|
||||
} else if err != nil {
|
||||
client.MessageChannel <- ClientMessage{MessageID: msg.MessageID, Command: ErrorCommand, Arguments: err.Error()}
|
||||
} else {
|
||||
msg := ClientMessage{MessageID: msg.MessageID, Command: SuccessCommand, origArguments: resp}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
client.MsgChannelKeepalive.Done()
|
||||
}
|
711
socketserver/server/handlecore.go
Normal file
711
socketserver/server/handlecore.go
Normal file
|
@ -0,0 +1,711 @@
|
|||
package server // import "bitbucket.org/stendec/frankerfacez/socketserver/server"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// SuccessCommand is a Reply Command to indicate success in reply to a C2S Command.
|
||||
const SuccessCommand Command = "ok"
|
||||
|
||||
// ErrorCommand is a Reply Command to indicate that a C2S Command failed.
|
||||
const ErrorCommand Command = "error"
|
||||
|
||||
// HelloCommand is a C2S Command.
|
||||
// HelloCommand must be the Command of the first ClientMessage sent during a connection.
|
||||
// Sending any other command will result in a CloseFirstMessageNotHello.
|
||||
const HelloCommand Command = "hello"
|
||||
|
||||
// ReadyCommand is a C2S Command.
|
||||
// It indicates that the client is finished sending the initial 'sub' commands and the server should send the backlog.
|
||||
const ReadyCommand Command = "ready"
|
||||
|
||||
const SetUserCommand Command = "setuser"
|
||||
|
||||
// AuthorizeCommand is a S2C Command sent as part of Twitch username validation.
|
||||
const AuthorizeCommand Command = "do_authorize"
|
||||
|
||||
// AsyncResponseCommand is a pseudo-Reply Command.
|
||||
// It indicates that the Reply Command to the client's C2S Command will be delivered
|
||||
// on a goroutine over the ClientInfo.MessageChannel and should not be delivered immediately.
|
||||
const AsyncResponseCommand Command = "_async"
|
||||
|
||||
const defaultMinMemoryKB = 1024 * 24
|
||||
|
||||
// DotTwitchDotTv is the .twitch.tv suffix.
|
||||
const DotTwitchDotTv = ".twitch.tv"
|
||||
|
||||
const dotCbenniDotCom = ".cbenni.com"
|
||||
|
||||
var OriginRegexp = regexp.MustCompile("(" + DotTwitchDotTv + "|" + dotCbenniDotCom + ")" + "$")
|
||||
|
||||
// ResponseSuccess is a Reply ClientMessage with the MessageID not yet filled out.
|
||||
var ResponseSuccess = ClientMessage{Command: SuccessCommand}
|
||||
|
||||
// Configuration is the active ConfigFile.
|
||||
var Configuration *ConfigFile
|
||||
|
||||
var janitorsOnce sync.Once
|
||||
|
||||
var CommandPool *StringPool
|
||||
var PubSubChannelPool *StringPool
|
||||
var TwitchChannelPool *StringPool
|
||||
|
||||
// SetupServerAndHandle starts all background goroutines and registers HTTP listeners on the given ServeMux.
|
||||
// Essentially, this function completely preps the server for a http.ListenAndServe call.
|
||||
// (Uses http.DefaultServeMux if `serveMux` is nil.)
|
||||
func SetupServerAndHandle(config *ConfigFile, serveMux *http.ServeMux) {
|
||||
Configuration = config
|
||||
|
||||
if config.MinMemoryKBytes == 0 {
|
||||
config.MinMemoryKBytes = defaultMinMemoryKB
|
||||
}
|
||||
|
||||
Backend = setupBackend(config)
|
||||
|
||||
if serveMux == nil {
|
||||
serveMux = http.DefaultServeMux
|
||||
}
|
||||
|
||||
bannerBytes, err := ioutil.ReadFile("index.html")
|
||||
if err != nil {
|
||||
log.Fatalln("Could not open index.html:", err)
|
||||
}
|
||||
BannerHTML = bannerBytes
|
||||
|
||||
serveMux.HandleFunc("/", HTTPHandleRootURL)
|
||||
serveMux.Handle("/.well-known/", http.FileServer(http.Dir("/tmp/letsencrypt/")))
|
||||
serveMux.HandleFunc("/healthcheck", HTTPSayOK)
|
||||
serveMux.HandleFunc("/stats", HTTPShowStatistics)
|
||||
serveMux.HandleFunc("/hll/", HTTPShowHLL)
|
||||
serveMux.HandleFunc("/hll_force_write", HTTPWriteHLL)
|
||||
|
||||
serveMux.HandleFunc("/drop_backlog", HTTPBackendDropBacklog)
|
||||
serveMux.HandleFunc("/uncached_pub", HTTPBackendUncachedPublish)
|
||||
serveMux.HandleFunc("/cached_pub", HTTPBackendCachedPublish)
|
||||
serveMux.HandleFunc("/get_sub_count", HTTPGetSubscriberCount)
|
||||
|
||||
announceForm, err := Backend.SealRequest(url.Values{
|
||||
"startup": []string{"1"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("Unable to seal requests:", err)
|
||||
}
|
||||
resp, err := Backend.HTTPClient.PostForm(Backend.announceStartupURL, announceForm)
|
||||
if err != nil {
|
||||
log.Println("could not announce startup to backend:", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
Backend.lastSuccessLock.Lock()
|
||||
Backend.lastSuccess[bPathAnnounceStartup] = time.Now().UTC()
|
||||
Backend.lastSuccessLock.Unlock()
|
||||
}
|
||||
|
||||
janitorsOnce.Do(startJanitors)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setupInterning()
|
||||
}
|
||||
|
||||
// startJanitors starts the 'is_init_func' goroutines
|
||||
func startJanitors() {
|
||||
loadUniqueUsers()
|
||||
|
||||
go authorizationJanitor()
|
||||
go aggregateDataSender()
|
||||
go bunchCacheJanitor()
|
||||
go cachedMessageJanitor()
|
||||
go commandCounter()
|
||||
go pubsubJanitor()
|
||||
|
||||
go ircConnection()
|
||||
go shutdownHandler()
|
||||
}
|
||||
|
||||
// is_init_func
|
||||
func shutdownHandler() {
|
||||
ch := make(chan os.Signal)
|
||||
signal.Notify(ch, syscall.SIGUSR1)
|
||||
signal.Notify(ch, syscall.SIGTERM)
|
||||
<-ch
|
||||
log.Println("Shutting down...")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
writeHLL()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
StopAcceptingConnections = true
|
||||
close(StopAcceptingConnectionsCh)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
wg.Wait()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// is_init_func +test
|
||||
func dumpStackOnCtrlZ() {
|
||||
ch := make(chan os.Signal)
|
||||
signal.Notify(ch, syscall.SIGTSTP)
|
||||
for range ch {
|
||||
fmt.Println("Got ^Z")
|
||||
|
||||
buf := make([]byte, 10000)
|
||||
byteCnt := runtime.Stack(buf, true)
|
||||
fmt.Println(string(buf[:byteCnt]))
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPSayOK replies with 200 and a body of "ok\n".
|
||||
func HTTPSayOK(w http.ResponseWriter, _ *http.Request) {
|
||||
w.(interface {
|
||||
WriteString(string) error
|
||||
}).WriteString("ok\n")
|
||||
}
|
||||
|
||||
// SocketUpgrader is the websocket.Upgrader currently in use.
|
||||
var SocketUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return r.Header.Get("Origin") == "" || OriginRegexp.MatchString(r.Header.Get("Origin"))
|
||||
},
|
||||
}
|
||||
|
||||
// BannerHTML is the content served to web browsers viewing the socket server website.
|
||||
// Memes go here.
|
||||
var BannerHTML []byte
|
||||
|
||||
// StopAcceptingConnectionsCh is closed while the server is shutting down.
|
||||
var StopAcceptingConnectionsCh = make(chan struct{})
|
||||
var StopAcceptingConnections = false
|
||||
|
||||
// HTTPHandleRootURL is the http.HandleFunc for requests on `/`.
|
||||
// It either uses the SocketUpgrader or writes out the BannerHTML.
|
||||
func HTTPHandleRootURL(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
fmt.Println(404)
|
||||
return
|
||||
}
|
||||
|
||||
// racy, but should be ok?
|
||||
if StopAcceptingConnections {
|
||||
w.WriteHeader(503)
|
||||
fmt.Fprint(w, "server is shutting down")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
||||
updateSysMem()
|
||||
|
||||
if Statistics.SysMemFreeKB > 0 && Statistics.SysMemFreeKB < Configuration.MinMemoryKBytes {
|
||||
atomic.AddUint64(&Statistics.LowMemDroppedConnections, 1)
|
||||
w.WriteHeader(503)
|
||||
fmt.Fprint(w, "error: low memory")
|
||||
return
|
||||
}
|
||||
|
||||
if Configuration.MaxClientCount != 0 {
|
||||
curClients := atomic.LoadUint64(&Statistics.CurrentClientCount)
|
||||
if curClients >= Configuration.MaxClientCount {
|
||||
w.WriteHeader(503)
|
||||
fmt.Fprint(w, "error: client limit reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := SocketUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "error: %v", err)
|
||||
return
|
||||
}
|
||||
RunSocketConnection(conn)
|
||||
|
||||
return
|
||||
} else {
|
||||
w.Write(BannerHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrProtocolGeneric is sent in a ErrorCommand Reply.
|
||||
var ErrProtocolGeneric error = errors.New("FFZ Socket protocol error.")
|
||||
|
||||
// ErrProtocolNegativeMsgID is sent in a ErrorCommand Reply when a negative MessageID is received.
|
||||
var ErrProtocolNegativeMsgID error = errors.New("FFZ Socket protocol error: negative or zero message ID.")
|
||||
|
||||
// ErrExpectedSingleString is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedSingleString = errors.New("Error: Expected single string as arguments.")
|
||||
|
||||
// ErrExpectedSingleInt is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedSingleInt = errors.New("Error: Expected single integer as arguments.")
|
||||
|
||||
// ErrExpectedTwoStrings is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedTwoStrings = errors.New("Error: Expected array of string, string as arguments.")
|
||||
|
||||
// ErrExpectedStringAndBool is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedStringAndBool = errors.New("Error: Expected array of string, bool as arguments.")
|
||||
|
||||
// ErrExpectedStringAndInt is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedStringAndInt = errors.New("Error: Expected array of string, int as arguments.")
|
||||
|
||||
// ErrExpectedStringAndIntGotFloat is sent in a ErrorCommand Reply when the Arguments are of the wrong type.
|
||||
var ErrExpectedStringAndIntGotFloat = errors.New("Error: Second argument was a float, expected an integer.")
|
||||
|
||||
// CloseGoingAway is sent when the server is restarting.
|
||||
var CloseGoingAway = websocket.CloseError{Code: websocket.CloseGoingAway, Text: "server restarting"}
|
||||
|
||||
// CloseRebalance is sent when the server has too many clients and needs to shunt some to another server.
|
||||
var CloseRebalance = websocket.CloseError{Code: websocket.CloseGoingAway, Text: "kicked for rebalancing, please select a new server"}
|
||||
|
||||
// CloseGotBinaryMessage is the termination reason when the client sends a binary websocket frame.
|
||||
var CloseGotBinaryMessage = websocket.CloseError{Code: websocket.CloseUnsupportedData, Text: "got binary packet"}
|
||||
|
||||
// CloseTimedOut is the termination reason when the client fails to send or respond to ping frames.
|
||||
var CloseTimedOut = websocket.CloseError{Code: 3003, Text: "no ping replies for 5 minutes"}
|
||||
|
||||
// CloseTooManyBufferedMessages is the termination reason when the sending thread buffers too many messages.
|
||||
var CloseTooManyBufferedMessages = websocket.CloseError{Code: websocket.CloseMessageTooBig, Text: "too many pending messages"}
|
||||
|
||||
// CloseFirstMessageNotHello is the termination reason
|
||||
var CloseFirstMessageNotHello = websocket.CloseError{
|
||||
Text: "Error - the first message sent must be a 'hello'",
|
||||
Code: websocket.ClosePolicyViolation,
|
||||
}
|
||||
|
||||
var CloseNonUTF8Data = websocket.CloseError{
|
||||
Code: websocket.CloseUnsupportedData,
|
||||
Text: "Non UTF8 data recieved. Network corruption likely.",
|
||||
}
|
||||
|
||||
const sendMessageBufferLength = 30
|
||||
const sendMessageAbortLength = 20
|
||||
|
||||
// RunSocketConnection contains the main run loop of a websocket connection.
|
||||
//
|
||||
// First, it sets up the channels, the ClientInfo object, and the pong frame handler.
|
||||
// It starts the reader goroutine pointing at the newly created channels.
|
||||
// The function then enters the run loop (a `for{select{}}`).
|
||||
// The run loop is broken when an object is received on errorChan, or if `hello` is not the first C2S Command.
|
||||
//
|
||||
// After the run loop stops, the function launches a goroutine to drain
|
||||
// client.MessageChannel, signals the reader goroutine to stop, unsubscribes
|
||||
// from all pub/sub channels, waits on MsgChannelKeepalive (remember, the
|
||||
// messages are being drained), and finally closes client.MessageChannel
|
||||
// (which ends the drainer goroutine).
|
||||
func RunSocketConnection(conn *websocket.Conn) {
|
||||
// websocket.Conn is a ReadWriteCloser
|
||||
|
||||
atomic.AddUint64(&Statistics.ClientConnectsTotal, 1)
|
||||
atomic.AddUint64(&Statistics.CurrentClientCount, 1)
|
||||
|
||||
_clientChan := make(chan ClientMessage)
|
||||
_serverMessageChan := make(chan ClientMessage, sendMessageBufferLength)
|
||||
_errorChan := make(chan error)
|
||||
stoppedChan := make(chan struct{})
|
||||
|
||||
var client ClientInfo
|
||||
client.MessageChannel = _serverMessageChan
|
||||
client.RemoteAddr = conn.RemoteAddr()
|
||||
client.MsgChannelIsDone = stoppedChan
|
||||
|
||||
// var report logstasher.ConnectionReport
|
||||
// report.ConnectTime = time.Now()
|
||||
// report.RemoteAddr = client.RemoteAddr
|
||||
|
||||
conn.SetPongHandler(func(pongBody string) error {
|
||||
client.Mutex.Lock()
|
||||
client.pingCount = 0
|
||||
client.Mutex.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// All set up, now enter the work loop
|
||||
go runSocketReader(conn, _errorChan, _clientChan, stoppedChan)
|
||||
closeReason := runSocketWriter(conn, &client, _errorChan, _clientChan, _serverMessageChan)
|
||||
|
||||
// Exit
|
||||
closeConnection(conn, closeReason)
|
||||
// closeConnection(conn, closeReason, &report)
|
||||
|
||||
// Launch message draining goroutine - we aren't out of the pub/sub records
|
||||
go func() {
|
||||
for _ = range _serverMessageChan {
|
||||
}
|
||||
}()
|
||||
|
||||
// Closes client.MsgChannelIsDone and also stops the reader thread
|
||||
close(stoppedChan)
|
||||
|
||||
// Stop getting messages...
|
||||
UnsubscribeAll(&client)
|
||||
|
||||
// Wait for pending jobs to finish...
|
||||
client.MsgChannelKeepalive.Wait()
|
||||
client.MessageChannel = nil
|
||||
|
||||
// And done.
|
||||
// Close the channel so the draining goroutine can finish, too.
|
||||
close(_serverMessageChan)
|
||||
|
||||
if !StopAcceptingConnections {
|
||||
// Don't perform high contention operations when server is closing
|
||||
atomic.AddUint64(&Statistics.CurrentClientCount, NegativeOne)
|
||||
atomic.AddUint64(&Statistics.ClientDisconnectsTotal, 1)
|
||||
|
||||
// report.UsernameWasValidated = client.UsernameValidated
|
||||
// report.TwitchUsername = client.TwitchUsername
|
||||
// logstasher.Submit(&report)
|
||||
}
|
||||
}
|
||||
|
||||
func runSocketReader(conn *websocket.Conn, errorChan chan<- error, clientChan chan<- ClientMessage, stoppedChan <-chan struct{}) {
|
||||
var msg ClientMessage
|
||||
var messageType int
|
||||
var packet []byte
|
||||
var err error
|
||||
|
||||
defer close(errorChan)
|
||||
defer close(clientChan)
|
||||
|
||||
for ; err == nil; messageType, packet, err = conn.ReadMessage() {
|
||||
if messageType == websocket.BinaryMessage {
|
||||
err = &CloseGotBinaryMessage
|
||||
break
|
||||
}
|
||||
if messageType == websocket.CloseMessage {
|
||||
err = io.EOF
|
||||
break
|
||||
}
|
||||
|
||||
UnmarshalClientMessage(packet, messageType, &msg)
|
||||
if msg.MessageID == 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case clientChan <- msg:
|
||||
case <-stoppedChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case errorChan <- err:
|
||||
case <-stoppedChan:
|
||||
}
|
||||
// exit goroutine
|
||||
}
|
||||
|
||||
func runSocketWriter(conn *websocket.Conn, client *ClientInfo, errorChan <-chan error, clientChan <-chan ClientMessage, serverMessageChan <-chan ClientMessage) websocket.CloseError {
|
||||
for {
|
||||
select {
|
||||
case err := <-errorChan:
|
||||
if err == io.EOF {
|
||||
return websocket.CloseError{
|
||||
Code: websocket.CloseGoingAway,
|
||||
Text: err.Error(),
|
||||
}
|
||||
} else if closeMsg, isClose := err.(*websocket.CloseError); isClose {
|
||||
return *closeMsg
|
||||
} else {
|
||||
return websocket.CloseError{
|
||||
Code: websocket.CloseInternalServerErr,
|
||||
Text: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
case msg := <-clientChan:
|
||||
if client.VersionString == "" && msg.Command != HelloCommand {
|
||||
return CloseFirstMessageNotHello
|
||||
}
|
||||
|
||||
for _, char := range msg.Command {
|
||||
if char == utf8.RuneError {
|
||||
return CloseNonUTF8Data
|
||||
}
|
||||
}
|
||||
|
||||
DispatchC2SCommand(conn, client, msg)
|
||||
|
||||
case msg := <-serverMessageChan:
|
||||
if len(serverMessageChan) > sendMessageAbortLength {
|
||||
return CloseTooManyBufferedMessages
|
||||
}
|
||||
if cls, ok := msg.Arguments.(*websocket.CloseError); ok {
|
||||
return *cls
|
||||
}
|
||||
SendMessage(conn, msg)
|
||||
|
||||
case <-time.After(1 * time.Minute):
|
||||
client.Mutex.Lock()
|
||||
client.pingCount++
|
||||
tooManyPings := client.pingCount == 5
|
||||
client.Mutex.Unlock()
|
||||
if tooManyPings {
|
||||
return CloseTimedOut
|
||||
} else {
|
||||
conn.WriteControl(websocket.PingMessage, []byte(strconv.FormatInt(time.Now().Unix(), 10)), getDeadline())
|
||||
}
|
||||
|
||||
case <-StopAcceptingConnectionsCh:
|
||||
return CloseGoingAway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getDeadline() time.Time {
|
||||
return time.Now().Add(1 * time.Minute)
|
||||
}
|
||||
|
||||
func closeConnection(conn *websocket.Conn, closeMsg websocket.CloseError) {
|
||||
closeTxt := closeMsg.Text
|
||||
if strings.Contains(closeTxt, "read: connection reset by peer") {
|
||||
closeTxt = "read: connection reset by peer"
|
||||
} else if strings.Contains(closeTxt, "use of closed network connection") {
|
||||
closeTxt = "read: use of closed network connection"
|
||||
} else if closeMsg.Code == 1001 {
|
||||
closeTxt = "clean shutdown"
|
||||
}
|
||||
|
||||
// report.DisconnectCode = closeMsg.Code
|
||||
// report.DisconnectReason = closeTxt
|
||||
// report.DisconnectTime = time.Now()
|
||||
|
||||
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(closeMsg.Code, closeMsg.Text), getDeadline())
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
// SendMessage sends a ClientMessage over the websocket connection with a timeout.
|
||||
// If marshalling the ClientMessage fails, this function will panic.
|
||||
func SendMessage(conn *websocket.Conn, msg ClientMessage) {
|
||||
messageType, packet, err := MarshalClientMessage(msg)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal: %v %v", err, msg))
|
||||
}
|
||||
conn.SetWriteDeadline(getDeadline())
|
||||
conn.WriteMessage(messageType, packet)
|
||||
atomic.AddUint64(&Statistics.MessagesSent, 1)
|
||||
}
|
||||
|
||||
// UnmarshalClientMessage unpacks websocket TextMessage into a ClientMessage provided in the `v` parameter.
|
||||
func UnmarshalClientMessage(data []byte, payloadType int, v interface{}) (err error) {
|
||||
var spaceIdx int
|
||||
|
||||
out := v.(*ClientMessage)
|
||||
dataStr := string(data)
|
||||
|
||||
// Message ID
|
||||
spaceIdx = strings.IndexRune(dataStr, ' ')
|
||||
if spaceIdx == -1 {
|
||||
return ErrProtocolGeneric
|
||||
}
|
||||
messageID, err := strconv.Atoi(dataStr[:spaceIdx])
|
||||
if messageID < -1 || messageID == 0 {
|
||||
return ErrProtocolNegativeMsgID
|
||||
}
|
||||
|
||||
out.MessageID = messageID
|
||||
dataStr = dataStr[spaceIdx+1:]
|
||||
|
||||
spaceIdx = strings.IndexRune(dataStr, ' ')
|
||||
if spaceIdx == -1 {
|
||||
out.Command = CommandPool.InternCommand(dataStr)
|
||||
out.Arguments = nil
|
||||
return nil
|
||||
} else {
|
||||
out.Command = CommandPool.InternCommand(dataStr[:spaceIdx])
|
||||
}
|
||||
dataStr = dataStr[spaceIdx+1:]
|
||||
argumentsJSON := string([]byte(dataStr))
|
||||
out.origArguments = argumentsJSON
|
||||
err = out.parseOrigArguments()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *ClientMessage) parseOrigArguments() error {
|
||||
err := json.Unmarshal([]byte(cm.origArguments), &cm.Arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalClientMessage(clientMessage interface{}) (payloadType int, data []byte, err error) {
|
||||
var msg ClientMessage
|
||||
var ok bool
|
||||
msg, ok = clientMessage.(ClientMessage)
|
||||
if !ok {
|
||||
pMsg, ok := clientMessage.(*ClientMessage)
|
||||
if !ok {
|
||||
panic("MarshalClientMessage: argument needs to be a ClientMessage")
|
||||
}
|
||||
msg = *pMsg
|
||||
}
|
||||
var dataStr string
|
||||
|
||||
if msg.Command == "" && msg.MessageID == 0 {
|
||||
panic("MarshalClientMessage: attempt to send an empty ClientMessage")
|
||||
}
|
||||
|
||||
if msg.Command == "" {
|
||||
msg.Command = SuccessCommand
|
||||
}
|
||||
if msg.MessageID == 0 {
|
||||
msg.MessageID = -1
|
||||
}
|
||||
|
||||
if msg.Arguments != nil {
|
||||
argBytes, err := json.Marshal(msg.Arguments)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
dataStr = fmt.Sprintf("%d %s %s", msg.MessageID, msg.Command, string(argBytes))
|
||||
} else {
|
||||
dataStr = fmt.Sprintf("%d %s", msg.MessageID, msg.Command)
|
||||
}
|
||||
|
||||
return websocket.TextMessage, []byte(dataStr), nil
|
||||
}
|
||||
|
||||
// ArgumentsAsString parses the arguments of the ClientMessage as a single string.
|
||||
func (cm *ClientMessage) ArgumentsAsString() (string1 string, err error) {
|
||||
var ok bool
|
||||
string1, ok = cm.Arguments.(string)
|
||||
if !ok {
|
||||
err = ErrExpectedSingleString
|
||||
return
|
||||
} else {
|
||||
return string1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ArgumentsAsInt parses the arguments of the ClientMessage as a single int.
|
||||
func (cm *ClientMessage) ArgumentsAsInt() (int1 int64, err error) {
|
||||
var ok bool
|
||||
var num float64
|
||||
num, ok = cm.Arguments.(float64)
|
||||
if !ok {
|
||||
err = ErrExpectedSingleInt
|
||||
return
|
||||
} else {
|
||||
int1 = int64(num)
|
||||
return int1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ArgumentsAsTwoStrings parses the arguments of the ClientMessage as an array of two strings.
|
||||
func (cm *ClientMessage) ArgumentsAsTwoStrings() (string1, string2 string, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
string1, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
// clientID can be null
|
||||
if ary[1] == nil {
|
||||
return string1, "", nil
|
||||
}
|
||||
string2, ok = ary[1].(string)
|
||||
if !ok {
|
||||
err = ErrExpectedTwoStrings
|
||||
return
|
||||
}
|
||||
return string1, string2, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ArgumentsAsStringAndInt parses the arguments of the ClientMessage as an array of a string and an int.
|
||||
func (cm *ClientMessage) ArgumentsAsStringAndInt() (string1 string, int int64, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndInt
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ErrExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
string1, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
var num float64
|
||||
num, ok = ary[1].(float64)
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndInt
|
||||
return
|
||||
}
|
||||
int = int64(num)
|
||||
if float64(int) != num {
|
||||
err = ErrExpectedStringAndIntGotFloat
|
||||
return
|
||||
}
|
||||
return string1, int, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ArgumentsAsStringAndBool parses the arguments of the ClientMessage as an array of a string and an int.
|
||||
func (cm *ClientMessage) ArgumentsAsStringAndBool() (str string, flag bool, err error) {
|
||||
var ok bool
|
||||
var ary []interface{}
|
||||
ary, ok = cm.Arguments.([]interface{})
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndBool
|
||||
return
|
||||
} else {
|
||||
if len(ary) != 2 {
|
||||
err = ErrExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
str, ok = ary[0].(string)
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
flag, ok = ary[1].(bool)
|
||||
if !ok {
|
||||
err = ErrExpectedStringAndBool
|
||||
return
|
||||
}
|
||||
return str, flag, nil
|
||||
}
|
||||
}
|
|
@ -2,14 +2,15 @@ package server
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/net/websocket"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func ExampleUnmarshalClientMessage() {
|
||||
sourceData := []byte("100 hello [\"ffz_3.5.30\",\"898b5bfa-b577-47bb-afb4-252c703b67d6\"]")
|
||||
var cm ClientMessage
|
||||
err := UnmarshalClientMessage(sourceData, websocket.TextFrame, &cm)
|
||||
err := UnmarshalClientMessage(sourceData, websocket.TextMessage, &cm)
|
||||
fmt.Println(err)
|
||||
fmt.Println(cm.MessageID)
|
||||
fmt.Println(cm.Command)
|
||||
|
@ -27,9 +28,9 @@ func ExampleMarshalClientMessage() {
|
|||
Command: "do_authorize",
|
||||
Arguments: "1234567890",
|
||||
}
|
||||
data, payloadType, err := MarshalClientMessage(&cm)
|
||||
payloadType, data, err := MarshalClientMessage(&cm)
|
||||
fmt.Println(err)
|
||||
fmt.Println(payloadType == websocket.TextFrame)
|
||||
fmt.Println(payloadType == websocket.TextMessage)
|
||||
fmt.Println(string(data))
|
||||
// Output:
|
||||
// <nil>
|
||||
|
@ -40,7 +41,7 @@ func ExampleMarshalClientMessage() {
|
|||
func TestArgumentsAsStringAndBool(t *testing.T) {
|
||||
sourceData := []byte("1 foo [\"string\", false]")
|
||||
var cm ClientMessage
|
||||
err := UnmarshalClientMessage(sourceData, websocket.TextFrame, &cm)
|
||||
err := UnmarshalClientMessage(sourceData, websocket.TextMessage, &cm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
42
socketserver/server/intern.go
Normal file
42
socketserver/server/intern.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type StringPool struct {
|
||||
sync.RWMutex
|
||||
lookup map[string]string
|
||||
}
|
||||
|
||||
func NewStringPool() *StringPool {
|
||||
return &StringPool{lookup: make(map[string]string)}
|
||||
}
|
||||
|
||||
// doesn't lock, doesn't check for dupes.
|
||||
func (p *StringPool) _Intern_Setup(s string) {
|
||||
p.lookup[s] = s
|
||||
}
|
||||
|
||||
func (p *StringPool) InternCommand(s string) Command {
|
||||
return Command(p.Intern(s))
|
||||
}
|
||||
|
||||
func (p *StringPool) Intern(s string) string {
|
||||
p.RLock()
|
||||
ss, exists := p.lookup[s]
|
||||
p.RUnlock()
|
||||
if exists {
|
||||
return ss
|
||||
}
|
||||
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
ss, exists = p.lookup[s]
|
||||
if exists {
|
||||
return ss
|
||||
}
|
||||
ss = copyString(s)
|
||||
p.lookup[ss] = ss
|
||||
return ss
|
||||
}
|
181
socketserver/server/irc.go
Normal file
181
socketserver/server/irc.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
irc "github.com/fluffle/goirc/client"
|
||||
)
|
||||
|
||||
type AuthCallback func(client *ClientInfo, successful bool)
|
||||
|
||||
type PendingAuthorization struct {
|
||||
Client *ClientInfo
|
||||
Challenge string
|
||||
Callback AuthCallback
|
||||
EnteredAt time.Time
|
||||
}
|
||||
|
||||
var PendingAuths []PendingAuthorization
|
||||
var PendingAuthLock sync.Mutex
|
||||
|
||||
func AddPendingAuthorization(client *ClientInfo, challenge string, callback AuthCallback) {
|
||||
PendingAuthLock.Lock()
|
||||
defer PendingAuthLock.Unlock()
|
||||
|
||||
PendingAuths = append(PendingAuths, PendingAuthorization{
|
||||
Client: client,
|
||||
Challenge: challenge,
|
||||
Callback: callback,
|
||||
EnteredAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// is_init_func
|
||||
func authorizationJanitor() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
authorizationJanitor_do()
|
||||
}
|
||||
}
|
||||
|
||||
func authorizationJanitor_do() {
|
||||
cullTime := time.Now().Add(-30 * time.Minute)
|
||||
|
||||
PendingAuthLock.Lock()
|
||||
defer PendingAuthLock.Unlock()
|
||||
|
||||
newPendingAuths := make([]PendingAuthorization, 0, len(PendingAuths))
|
||||
|
||||
for _, v := range PendingAuths {
|
||||
if !cullTime.After(v.EnteredAt) {
|
||||
newPendingAuths = append(newPendingAuths, v)
|
||||
} else {
|
||||
go v.Callback(v.Client, false)
|
||||
}
|
||||
}
|
||||
|
||||
PendingAuths = newPendingAuths
|
||||
}
|
||||
|
||||
func (client *ClientInfo) StartAuthorization(callback AuthCallback) {
|
||||
if callback == nil {
|
||||
return // callback must not be nil
|
||||
}
|
||||
var nonce [32]byte
|
||||
_, err := rand.Read(nonce[:])
|
||||
if err != nil {
|
||||
go callback(client, false)
|
||||
return
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
enc := base64.NewEncoder(base64.RawURLEncoding, buf)
|
||||
enc.Write(nonce[:])
|
||||
enc.Close()
|
||||
challenge := buf.String()
|
||||
|
||||
AddPendingAuthorization(client, challenge, callback)
|
||||
|
||||
client.MessageChannel <- ClientMessage{MessageID: -1, Command: AuthorizeCommand, Arguments: challenge}
|
||||
}
|
||||
|
||||
const AuthChannelName = "frankerfacezauthorizer"
|
||||
const AuthChannel = "#" + AuthChannelName
|
||||
const AuthCommand = "AUTH"
|
||||
|
||||
var authIrcConnection *irc.Conn
|
||||
|
||||
// is_init_func
|
||||
func ircConnection() {
|
||||
c := irc.SimpleClient("justinfan123")
|
||||
c.Config().Server = "irc.chat.twitch.tv"
|
||||
authIrcConnection = c
|
||||
|
||||
var reconnect func(conn *irc.Conn)
|
||||
connect := func(conn *irc.Conn) {
|
||||
err := c.Connect()
|
||||
if err != nil {
|
||||
log.Println("irc: failed to connect to IRC:", err)
|
||||
go reconnect(conn)
|
||||
}
|
||||
}
|
||||
|
||||
reconnect = func(conn *irc.Conn) {
|
||||
time.Sleep(5 * time.Second)
|
||||
log.Println("irc: Reconnecting…")
|
||||
connect(conn)
|
||||
}
|
||||
|
||||
c.HandleFunc(irc.CONNECTED, func(conn *irc.Conn, line *irc.Line) {
|
||||
conn.Join(AuthChannel)
|
||||
})
|
||||
|
||||
c.HandleFunc(irc.DISCONNECTED, func(conn *irc.Conn, line *irc.Line) {
|
||||
log.Println("irc: Disconnected. Reconnecting in 5 seconds.")
|
||||
go reconnect(conn)
|
||||
})
|
||||
|
||||
c.HandleFunc(irc.PRIVMSG, func(conn *irc.Conn, line *irc.Line) {
|
||||
channel := line.Args[0]
|
||||
msg := line.Args[1]
|
||||
if channel != AuthChannel || !strings.HasPrefix(msg, AuthCommand) || !line.Public() {
|
||||
return
|
||||
}
|
||||
|
||||
msgArray := strings.Split(msg, " ")
|
||||
if len(msgArray) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
submittedUser := line.Nick
|
||||
submittedChallenge := msgArray[1]
|
||||
|
||||
submitAuth(submittedUser, submittedChallenge)
|
||||
})
|
||||
|
||||
connect(c)
|
||||
}
|
||||
|
||||
func submitAuth(user, challenge string) {
|
||||
var auth PendingAuthorization
|
||||
var idx int = -1
|
||||
|
||||
PendingAuthLock.Lock()
|
||||
for i, v := range PendingAuths {
|
||||
if v.Client.TwitchUsername == user && v.Challenge == challenge {
|
||||
auth = v
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx != -1 {
|
||||
PendingAuths = append(PendingAuths[:idx], PendingAuths[idx+1:]...)
|
||||
}
|
||||
PendingAuthLock.Unlock()
|
||||
|
||||
if idx == -1 {
|
||||
return // perhaps it was for another socket server
|
||||
}
|
||||
|
||||
// auth is valid, and removed from pending list
|
||||
|
||||
var usernameChanged bool
|
||||
auth.Client.Mutex.Lock()
|
||||
if auth.Client.TwitchUsername == user { // recheck condition
|
||||
auth.Client.UsernameValidated = true
|
||||
} else {
|
||||
usernameChanged = true
|
||||
}
|
||||
auth.Client.Mutex.Unlock()
|
||||
|
||||
if !usernameChanged {
|
||||
auth.Callback(auth.Client, true)
|
||||
} else {
|
||||
auth.Callback(auth.Client, false)
|
||||
}
|
||||
}
|
209
socketserver/server/logstasher/elasticsearch.go
Normal file
209
socketserver/server/logstasher/elasticsearch.go
Normal file
|
@ -0,0 +1,209 @@
|
|||
package logstasher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ID is a 128-bit ID for an elasticsearch document.
|
||||
// Textually, it is base64-encoded.
|
||||
// The Next() method increments the ID.
|
||||
type ID struct {
|
||||
High uint64
|
||||
Low uint64
|
||||
}
|
||||
|
||||
// Text converts the ID into a base64 string.
|
||||
func (id ID) String() string {
|
||||
var buf bytes.Buffer
|
||||
buf.Grow(21)
|
||||
enc := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
var bytes [16]byte
|
||||
binary.LittleEndian.PutUint64(bytes[0:8], id.High)
|
||||
binary.LittleEndian.PutUint64(bytes[8:16], id.Low)
|
||||
enc.Write(bytes[:])
|
||||
enc.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Next increments the ID and returns the prior state.
|
||||
// Overflow is not checked because it's a uint64, do you really expect me to overflow that
|
||||
func (id *ID) Next() ID {
|
||||
ret := ID{
|
||||
High: id.High,
|
||||
Low: id.Low,
|
||||
}
|
||||
id.Low++
|
||||
return ret
|
||||
}
|
||||
|
||||
var idPool = sync.Pool{New: func() interface{} {
|
||||
var bytes [16]byte
|
||||
n, err := rand.Reader.Read(bytes[:])
|
||||
if n != 16 || err != nil {
|
||||
panic(fmt.Errorf("Short read from crypto/rand: %v", err))
|
||||
}
|
||||
|
||||
return &ID{
|
||||
High: binary.LittleEndian.Uint64(bytes[0:8]),
|
||||
Low: binary.LittleEndian.Uint64(bytes[8:16]),
|
||||
}
|
||||
}}
|
||||
|
||||
func ExampleID_Next() {
|
||||
id := idPool.Get().(*ID).Next()
|
||||
fmt.Println(id)
|
||||
idPool.Put(id)
|
||||
}
|
||||
|
||||
// Report is the interface presented to the Submit() function.
|
||||
// FillReport() is satisfied by ReportBasic, but ReportType must always be specified.
|
||||
type Report interface {
|
||||
FillReport() error
|
||||
ReportType() string
|
||||
|
||||
GetID() string
|
||||
GetTimestamp() time.Time
|
||||
}
|
||||
|
||||
// ReportBasic is the essential fields of any report.
|
||||
type ReportBasic struct {
|
||||
ID string
|
||||
Timestamp time.Time
|
||||
Host string
|
||||
}
|
||||
|
||||
// FillReport sets the Host and Timestamp fields.
|
||||
func (report *ReportBasic) FillReport() error {
|
||||
report.Host = hostMarker
|
||||
report.Timestamp = time.Now()
|
||||
id := idPool.Get().(*ID).Next()
|
||||
report.ID = id.String()
|
||||
idPool.Put(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (report *ReportBasic) GetID() string {
|
||||
return report.ID
|
||||
}
|
||||
|
||||
func (report *ReportBasic) GetTimestamp() time.Time {
|
||||
return report.Timestamp
|
||||
}
|
||||
|
||||
type ConnectionReport struct {
|
||||
ReportBasic
|
||||
|
||||
ConnectTime time.Time
|
||||
DisconnectTime time.Time
|
||||
// calculated
|
||||
ConnectionDuration time.Duration
|
||||
|
||||
DisconnectCode int
|
||||
DisconnectReason string
|
||||
|
||||
UsernameWasValidated bool
|
||||
|
||||
RemoteAddr net.Addr `json:"-"` // not transmitted until I can figure out data minimization
|
||||
TwitchUsername string `json:"-"` // also not transmitted
|
||||
}
|
||||
|
||||
// FillReport sets all the calculated fields, and calls esReportBasic.FillReport().
|
||||
func (report *ConnectionReport) FillReport() error {
|
||||
report.ReportBasic.FillReport()
|
||||
report.ConnectionDuration = report.DisconnectTime.Sub(report.ConnectTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (report *ConnectionReport) ReportType() string {
|
||||
return "conn"
|
||||
}
|
||||
|
||||
var serverPresent bool
|
||||
var esClient http.Client
|
||||
var submitChan chan Report
|
||||
var serverBase, indexPrefix, hostMarker string
|
||||
|
||||
func checkServerPresent() {
|
||||
if serverBase == "" {
|
||||
serverBase = "http://localhost:9200"
|
||||
}
|
||||
if indexPrefix == "" {
|
||||
indexPrefix = "sockreport"
|
||||
}
|
||||
|
||||
urlHealth := fmt.Sprintf("%s/_cluster/health", serverBase)
|
||||
resp, err := esClient.Get(urlHealth)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
serverPresent = true
|
||||
submitChan = make(chan Report, 8)
|
||||
fmt.Println("elasticsearch reports enabled")
|
||||
go submissionWorker()
|
||||
} else {
|
||||
serverPresent = false
|
||||
}
|
||||
}
|
||||
|
||||
// Setup sets up the global variables for the package.
|
||||
func Setup(ESServer, ESIndexPrefix, ESHostname string) {
|
||||
serverBase = ESServer
|
||||
indexPrefix = ESIndexPrefix
|
||||
hostMarker = ESHostname
|
||||
checkServerPresent()
|
||||
}
|
||||
|
||||
// Submit inserts a report into elasticsearch (this is basically a manual logstash).
|
||||
func Submit(report Report) {
|
||||
if !serverPresent {
|
||||
return
|
||||
}
|
||||
|
||||
report.FillReport()
|
||||
submitChan <- report
|
||||
}
|
||||
|
||||
func submissionWorker() {
|
||||
for report := range submitChan {
|
||||
time := report.GetTimestamp()
|
||||
rType := report.ReportType()
|
||||
|
||||
// prefix-type-date
|
||||
indexName := fmt.Sprintf("%s-%s-%d-%d-%d", indexPrefix, rType, time.Year(), time.Month(), time.Day())
|
||||
// base/index/type/id
|
||||
putUrl, err := url.Parse(fmt.Sprintf("%s/%s/%s/%s", serverBase, indexName, rType, report.GetID()))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("logstash: cannot parse url: %v", err))
|
||||
}
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("logstash: cannot marshal json: %v", err))
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Method: "PUT",
|
||||
URL: putUrl,
|
||||
Body: ioutil.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
resp, err := esClient.Do(req)
|
||||
|
||||
if err != nil {
|
||||
// ignore, the show must go on
|
||||
} else {
|
||||
io.Copy(ioutil.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
239
socketserver/server/publisher.go
Normal file
239
socketserver/server/publisher.go
Normal file
|
@ -0,0 +1,239 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LastSavedMessage struct {
|
||||
Expires time.Time
|
||||
Data string
|
||||
}
|
||||
|
||||
// map is command -> channel -> data
|
||||
|
||||
// CachedLastMessages is of CacheTypeLastOnly.
|
||||
// Not actually cleaned up by reaper goroutine every ~hour.
|
||||
var CachedLastMessages = make(map[Command]map[string]LastSavedMessage)
|
||||
var CachedLSMLock sync.RWMutex
|
||||
|
||||
func cachedMessageJanitor() {
|
||||
for {
|
||||
time.Sleep(1*time.Hour)
|
||||
cachedMessageJanitor_do()
|
||||
}
|
||||
}
|
||||
|
||||
func cachedMessageJanitor_do() {
|
||||
CachedLSMLock.Lock()
|
||||
defer CachedLSMLock.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for cmd, chanMap := range CachedLastMessages {
|
||||
for channel, msg := range chanMap {
|
||||
if !msg.Expires.IsZero() && msg.Expires.Before(now) {
|
||||
delete(chanMap, channel)
|
||||
}
|
||||
}
|
||||
if len(chanMap) == 0 {
|
||||
delete(CachedLastMessages, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DumpBacklogData drops all /cached_pub data.
|
||||
func DumpBacklogData() {
|
||||
CachedLSMLock.Lock()
|
||||
CachedLastMessages = make(map[Command]map[string]LastSavedMessage)
|
||||
CachedLSMLock.Unlock()
|
||||
}
|
||||
|
||||
// SendBacklogForNewClient sends any backlog data relevant to a new client.
|
||||
// This should be done when the client sends a `ready` message.
|
||||
// This will only send data for CacheTypePersistent and CacheTypeLastOnly because those do not involve timestamps.
|
||||
func SendBacklogForNewClient(client *ClientInfo) {
|
||||
client.Mutex.Lock() // reading CurrentChannels
|
||||
curChannels := make([]string, len(client.CurrentChannels))
|
||||
copy(curChannels, client.CurrentChannels)
|
||||
client.Mutex.Unlock()
|
||||
|
||||
CachedLSMLock.RLock()
|
||||
for cmd, chanMap := range CachedLastMessages {
|
||||
if chanMap == nil {
|
||||
continue
|
||||
}
|
||||
for _, channel := range curChannels {
|
||||
msg, ok := chanMap[channel]
|
||||
if ok {
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: msg.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
CachedLSMLock.RUnlock()
|
||||
}
|
||||
|
||||
func SendBacklogForChannel(client *ClientInfo, channel string) {
|
||||
CachedLSMLock.RLock()
|
||||
for cmd, chanMap := range CachedLastMessages {
|
||||
if chanMap == nil {
|
||||
continue
|
||||
}
|
||||
if msg, ok := chanMap[channel]; ok {
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: msg.Data}
|
||||
msg.parseOrigArguments()
|
||||
client.MessageChannel <- msg
|
||||
}
|
||||
}
|
||||
CachedLSMLock.RUnlock()
|
||||
}
|
||||
|
||||
type timestampArray interface {
|
||||
Len() int
|
||||
GetTime(int) time.Time
|
||||
}
|
||||
|
||||
// the CachedLSMLock must be held when calling this
|
||||
func saveLastMessage(cmd Command, channel string, expires time.Time, data string, deleting bool) {
|
||||
chanMap, ok := CachedLastMessages[cmd]
|
||||
if !ok {
|
||||
if deleting {
|
||||
return
|
||||
}
|
||||
chanMap = make(map[string]LastSavedMessage)
|
||||
CachedLastMessages[cmd] = chanMap
|
||||
}
|
||||
|
||||
if deleting {
|
||||
delete(chanMap, channel)
|
||||
} else {
|
||||
chanMap[channel] = LastSavedMessage{Expires: expires, Data: data}
|
||||
}
|
||||
}
|
||||
|
||||
func HTTPBackendDropBacklog(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := Backend.UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
confirm := formData.Get("confirm")
|
||||
if confirm == "1" {
|
||||
DumpBacklogData()
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPBackendCachedPublish handles the /cached_pub route.
|
||||
// It publishes a message to clients, and then updates the in-server cache for the message.
|
||||
//
|
||||
// The 'channel' parameter is a comma-separated list of topics to publish the message to.
|
||||
// The 'args' parameter is the JSON-encoded command data.
|
||||
// If the 'delete' parameter is present, an entry is removed from the cache instead of publishing a message.
|
||||
// If the 'expires' parameter is not specified, the message will not expire (though it is only kept in-memory).
|
||||
func HTTPBackendCachedPublish(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := Backend.UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := CommandPool.InternCommand(formData.Get("cmd"))
|
||||
json := formData.Get("args")
|
||||
channel := formData.Get("channel")
|
||||
deleteMode := formData.Get("delete") != ""
|
||||
timeStr := formData.Get("expires")
|
||||
var expires time.Time
|
||||
if timeStr != "" {
|
||||
timeNum, err := strconv.ParseInt(timeStr, 10, 64)
|
||||
if err != nil {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "error parsing time: %v", err)
|
||||
return
|
||||
}
|
||||
expires = time.Unix(timeNum, 0)
|
||||
}
|
||||
|
||||
var count int
|
||||
msg := ClientMessage{MessageID: -1, Command: cmd, origArguments: json}
|
||||
msg.parseOrigArguments()
|
||||
|
||||
channels := strings.Split(channel, ",")
|
||||
CachedLSMLock.Lock()
|
||||
for _, channel := range channels {
|
||||
saveLastMessage(cmd, channel, expires, json, deleteMode)
|
||||
}
|
||||
CachedLSMLock.Unlock()
|
||||
count = PublishToMultiple(channels, msg)
|
||||
|
||||
w.Write([]byte(strconv.Itoa(count)))
|
||||
}
|
||||
|
||||
// HTTPBackendUncachedPublish handles the /uncached_pub route.
|
||||
// The backend can POST here to publish a message to clients with no caching.
|
||||
// The POST arguments are `cmd`, `args`, `channel`, and `scope`.
|
||||
// If "scope" is "global", then "channel" is not used.
|
||||
func HTTPBackendUncachedPublish(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := Backend.UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := formData.Get("cmd")
|
||||
json := formData.Get("args")
|
||||
channel := formData.Get("channel")
|
||||
scope := formData.Get("scope")
|
||||
|
||||
if cmd == "" {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "Error: cmd cannot be blank")
|
||||
return
|
||||
}
|
||||
if channel == "" && scope != "global" {
|
||||
w.WriteHeader(422)
|
||||
fmt.Fprintf(w, "Error: channel must be specified")
|
||||
return
|
||||
}
|
||||
|
||||
cm := ClientMessage{MessageID: -1, Command: CommandPool.InternCommand(cmd), origArguments: json}
|
||||
cm.parseOrigArguments()
|
||||
var count int
|
||||
|
||||
switch scope {
|
||||
default:
|
||||
count = PublishToMultiple(strings.Split(channel, ","), cm)
|
||||
case "global":
|
||||
count = PublishToAll(cm)
|
||||
}
|
||||
fmt.Fprint(w, count)
|
||||
}
|
||||
|
||||
// HTTPGetSubscriberCount handles the /get_sub_count route.
|
||||
// It replies with the number of clients subscribed to a pub/sub topic.
|
||||
// A "global" option is not available, use fetch(/stats).CurrentClientCount instead.
|
||||
func HTTPGetSubscriberCount(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
formData, err := Backend.UnsealRequest(r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
channel := formData.Get("channel")
|
||||
|
||||
fmt.Fprint(w, CountSubscriptions(strings.Split(channel, ",")))
|
||||
}
|
66
socketserver/server/publisher_test.go
Normal file
66
socketserver/server/publisher_test.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExpiredCleanup(t *testing.T) {
|
||||
const cmd = "test_command"
|
||||
const channel = "trihex"
|
||||
const channel2 = "twitch"
|
||||
const channel3 = "360chrism"
|
||||
const channel4 = "qa_partner"
|
||||
|
||||
DumpBacklogData()
|
||||
defer DumpBacklogData()
|
||||
|
||||
var zeroTime time.Time
|
||||
hourAgo := time.Now().Add(-1*time.Hour)
|
||||
now := time.Now()
|
||||
hourFromNow := time.Now().Add(1*time.Hour)
|
||||
|
||||
saveLastMessage(cmd, channel, hourAgo, "1", false)
|
||||
saveLastMessage(cmd, channel2, now, "2", false)
|
||||
|
||||
if len(CachedLastMessages) != 1 {
|
||||
t.Error("messages not saved")
|
||||
}
|
||||
if len(CachedLastMessages[cmd]) != 2{
|
||||
t.Error("messages not saved")
|
||||
}
|
||||
|
||||
time.Sleep(2*time.Millisecond)
|
||||
|
||||
cachedMessageJanitor_do()
|
||||
|
||||
if len(CachedLastMessages) != 0 {
|
||||
t.Error("messages still present")
|
||||
}
|
||||
|
||||
saveLastMessage(cmd, channel, hourAgo, "1", false)
|
||||
saveLastMessage(cmd, channel2, now, "2", false)
|
||||
saveLastMessage(cmd, channel3, hourFromNow, "3", false)
|
||||
saveLastMessage(cmd, channel4, zeroTime, "4", false)
|
||||
|
||||
if len(CachedLastMessages[cmd]) != 4 {
|
||||
t.Error("messages not saved")
|
||||
}
|
||||
|
||||
time.Sleep(2*time.Millisecond)
|
||||
|
||||
cachedMessageJanitor_do()
|
||||
|
||||
if len(CachedLastMessages) != 1 {
|
||||
t.Error("messages not saved")
|
||||
}
|
||||
if len(CachedLastMessages[cmd]) != 2 {
|
||||
t.Error("messages not saved")
|
||||
}
|
||||
if CachedLastMessages[cmd][channel3].Data != "3" {
|
||||
t.Error("saved wrong message")
|
||||
}
|
||||
if CachedLastMessages[cmd][channel4].Data != "4" {
|
||||
t.Error("saved wrong message")
|
||||
}
|
||||
}
|
216
socketserver/server/stats.go
Normal file
216
socketserver/server/stats.go
Normal file
|
@ -0,0 +1,216 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
linuxproc "github.com/c9s/goprocinfo/linux"
|
||||
)
|
||||
|
||||
type StatsData struct {
|
||||
StatsDataVersion int
|
||||
|
||||
StartTime time.Time
|
||||
Uptime string
|
||||
BuildTime string
|
||||
BuildHash string
|
||||
|
||||
CachedStatsLastUpdate time.Time
|
||||
|
||||
Health struct {
|
||||
IRC bool
|
||||
Backend map[string]time.Time
|
||||
}
|
||||
|
||||
CurrentClientCount uint64
|
||||
|
||||
PubSubChannelCount int
|
||||
|
||||
SysMemTotalKB uint64
|
||||
SysMemFreeKB uint64
|
||||
MemoryInUseKB uint64
|
||||
MemoryRSSKB uint64
|
||||
|
||||
LowMemDroppedConnections uint64
|
||||
|
||||
MemPerClientBytes uint64
|
||||
|
||||
CpuUsagePct float64
|
||||
|
||||
ClientConnectsTotal uint64
|
||||
ClientDisconnectsTotal uint64
|
||||
|
||||
ClientVersions map[string]uint64
|
||||
|
||||
DisconnectCodes map[string]uint64
|
||||
|
||||
CommandsIssuedTotal uint64
|
||||
CommandsIssuedMap map[Command]uint64
|
||||
|
||||
MessagesSent uint64
|
||||
|
||||
EmotesReportedTotal uint64
|
||||
|
||||
BackendVerifyFails uint64
|
||||
|
||||
// DisconnectReasons is at the bottom because it has indeterminate size
|
||||
DisconnectReasons map[string]uint64
|
||||
}
|
||||
|
||||
// Statistics is several variables that get incremented during normal operation of the server.
|
||||
// Its structure should be versioned as it is exposed via JSON.
|
||||
//
|
||||
// Note as to threaded access - this is soft/fun data and not critical to data integrity.
|
||||
// Fix anything that -race turns up, but otherwise it's not too much of a problem.
|
||||
var Statistics = newStatsData()
|
||||
|
||||
// CommandCounter is a channel for race-free counting of command usage.
|
||||
var CommandCounter = make(chan Command, 10)
|
||||
|
||||
// commandCounter receives from the CommandCounter channel and uses the value to increment the values in Statistics.
|
||||
// is_init_func
|
||||
func commandCounter() {
|
||||
for cmd := range CommandCounter {
|
||||
Statistics.CommandsIssuedTotal++
|
||||
Statistics.CommandsIssuedMap[cmd]++
|
||||
}
|
||||
}
|
||||
|
||||
// StatsDataVersion is the version of the StatsData struct.
|
||||
const StatsDataVersion = 6
|
||||
const pageSize = 4096
|
||||
|
||||
var cpuUsage struct {
|
||||
UserTime uint64
|
||||
SysTime uint64
|
||||
}
|
||||
|
||||
func newStatsData() *StatsData {
|
||||
return &StatsData{
|
||||
StartTime: time.Now(),
|
||||
CommandsIssuedMap: make(map[Command]uint64),
|
||||
DisconnectCodes: make(map[string]uint64),
|
||||
DisconnectReasons: make(map[string]uint64),
|
||||
ClientVersions: make(map[string]uint64),
|
||||
StatsDataVersion: StatsDataVersion,
|
||||
Health: struct {
|
||||
IRC bool
|
||||
Backend map[string]time.Time
|
||||
}{
|
||||
Backend: make(map[string]time.Time),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetBuildStamp should be called from the main package to identify the git build hash and build time.
|
||||
func SetBuildStamp(buildTime, buildHash string) {
|
||||
Statistics.BuildTime = buildTime
|
||||
Statistics.BuildHash = buildHash
|
||||
}
|
||||
|
||||
func updateStatsIfNeeded() {
|
||||
if time.Now().Add(-2 * time.Second).After(Statistics.CachedStatsLastUpdate) {
|
||||
updatePeriodicStats()
|
||||
}
|
||||
}
|
||||
|
||||
func updatePeriodicStats() {
|
||||
nowUpdate := time.Now()
|
||||
timeDiff := nowUpdate.Sub(Statistics.CachedStatsLastUpdate)
|
||||
Statistics.CachedStatsLastUpdate = nowUpdate
|
||||
|
||||
{
|
||||
m := runtime.MemStats{}
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
Statistics.MemoryInUseKB = m.Alloc / 1024
|
||||
}
|
||||
|
||||
{
|
||||
pstat, err := linuxproc.ReadProcessStat("/proc/self/stat")
|
||||
if err == nil {
|
||||
userTicks := pstat.Utime - cpuUsage.UserTime
|
||||
sysTicks := pstat.Stime - cpuUsage.SysTime
|
||||
cpuUsage.UserTime = pstat.Utime
|
||||
cpuUsage.SysTime = pstat.Stime
|
||||
|
||||
Statistics.CpuUsagePct = 100 * float64(userTicks+sysTicks) / (timeDiff.Seconds() * float64(ticksPerSecond))
|
||||
Statistics.MemoryRSSKB = uint64(pstat.Rss * pageSize / 1024)
|
||||
Statistics.MemPerClientBytes = (Statistics.MemoryRSSKB * 1024) / (Statistics.CurrentClientCount + 1)
|
||||
}
|
||||
updateSysMem()
|
||||
}
|
||||
|
||||
{
|
||||
ChatSubscriptionLock.RLock()
|
||||
Statistics.PubSubChannelCount = len(ChatSubscriptionInfo)
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
|
||||
GlobalSubscriptionLock.RLock()
|
||||
|
||||
Statistics.CurrentClientCount = uint64(len(GlobalSubscriptionInfo))
|
||||
versions := make(map[string]uint64)
|
||||
for _, v := range GlobalSubscriptionInfo {
|
||||
versions[v.VersionString]++
|
||||
}
|
||||
Statistics.ClientVersions = versions
|
||||
|
||||
GlobalSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
{
|
||||
Statistics.Uptime = nowUpdate.Sub(Statistics.StartTime).String()
|
||||
}
|
||||
|
||||
{
|
||||
Statistics.Health.IRC = authIrcConnection.Connected()
|
||||
Backend.lastSuccessLock.Lock()
|
||||
for k, v := range Backend.lastSuccess {
|
||||
Statistics.Health.Backend[k] = v
|
||||
}
|
||||
Backend.lastSuccessLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
var sysMemLastUpdate time.Time
|
||||
var sysMemUpdateLock sync.Mutex
|
||||
|
||||
// updateSysMem reads the system's available RAM.
|
||||
func updateSysMem() {
|
||||
if time.Now().Add(-2 * time.Second).After(sysMemLastUpdate) {
|
||||
sysMemUpdateLock.Lock()
|
||||
defer sysMemUpdateLock.Unlock()
|
||||
if !time.Now().Add(-2 * time.Second).After(sysMemLastUpdate) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
sysMemLastUpdate = time.Now()
|
||||
memInfo, err := linuxproc.ReadMemInfo("/proc/meminfo")
|
||||
if err == nil {
|
||||
Statistics.SysMemTotalKB = memInfo.MemTotal
|
||||
Statistics.SysMemFreeKB = memInfo.MemAvailable
|
||||
}
|
||||
|
||||
{
|
||||
writeHLL()
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPShowStatistics handles the /stats endpoint. It writes out the Statistics object as indented JSON.
|
||||
func HTTPShowStatistics(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
updateStatsIfNeeded()
|
||||
|
||||
jsonBytes, _ := json.Marshal(Statistics)
|
||||
outBuf := bytes.NewBuffer(nil)
|
||||
json.Indent(outBuf, jsonBytes, "", "\t")
|
||||
|
||||
outBuf.WriteTo(w)
|
||||
}
|
|
@ -4,6 +4,7 @@ package server
|
|||
// If I screwed up the locking, I won't know until it's too late.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
@ -15,9 +16,43 @@ type SubscriberList struct {
|
|||
|
||||
var ChatSubscriptionInfo map[string]*SubscriberList = make(map[string]*SubscriberList)
|
||||
var ChatSubscriptionLock sync.RWMutex
|
||||
var GlobalSubscriptionInfo SubscriberList
|
||||
var GlobalSubscriptionInfo []*ClientInfo
|
||||
var GlobalSubscriptionLock sync.RWMutex
|
||||
|
||||
func PublishToChat(channel string, msg ClientMessage) (count int) {
|
||||
func CountSubscriptions(channels []string) int {
|
||||
ChatSubscriptionLock.RLock()
|
||||
defer ChatSubscriptionLock.RUnlock()
|
||||
|
||||
count := 0
|
||||
for _, channelName := range channels {
|
||||
list := ChatSubscriptionInfo[channelName]
|
||||
if list != nil {
|
||||
list.RLock()
|
||||
count += len(list.Members)
|
||||
list.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func SubscribeChannel(client *ClientInfo, channelName string) {
|
||||
ChatSubscriptionLock.RLock()
|
||||
_subscribeWhileRlocked(channelName, client.MessageChannel)
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
func SubscribeDefaults(client *ClientInfo) {
|
||||
|
||||
}
|
||||
|
||||
func SubscribeGlobal(client *ClientInfo) {
|
||||
GlobalSubscriptionLock.Lock()
|
||||
AddToSliceCl(&GlobalSubscriptionInfo, client)
|
||||
GlobalSubscriptionLock.Unlock()
|
||||
}
|
||||
|
||||
func PublishToChannel(channel string, msg ClientMessage) (count int) {
|
||||
ChatSubscriptionLock.RLock()
|
||||
list := ChatSubscriptionInfo[channel]
|
||||
if list != nil {
|
||||
|
@ -58,15 +93,99 @@ func PublishToMultiple(channels []string, msg ClientMessage) (count int) {
|
|||
}
|
||||
|
||||
func PublishToAll(msg ClientMessage) (count int) {
|
||||
GlobalSubscriptionInfo.RLock()
|
||||
for _, msgChan := range GlobalSubscriptionInfo.Members {
|
||||
msgChan <- msg
|
||||
GlobalSubscriptionLock.RLock()
|
||||
for _, client := range GlobalSubscriptionInfo {
|
||||
select {
|
||||
case client.MessageChannel <- msg:
|
||||
case <-client.MsgChannelIsDone:
|
||||
}
|
||||
count++
|
||||
}
|
||||
GlobalSubscriptionInfo.RUnlock()
|
||||
GlobalSubscriptionLock.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
func UnsubscribeSingleChat(client *ClientInfo, channelName string) {
|
||||
ChatSubscriptionLock.RLock()
|
||||
list := ChatSubscriptionInfo[channelName]
|
||||
if list != nil {
|
||||
list.Lock()
|
||||
RemoveFromSliceC(&list.Members, client.MessageChannel)
|
||||
list.Unlock()
|
||||
}
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
// UnsubscribeAll will unsubscribe the client from all channels,
|
||||
// AND clear the CurrentChannels / WatchingChannels fields.
|
||||
//
|
||||
// Locks:
|
||||
// - read lock to top-level maps
|
||||
// - write lock to SubscriptionInfos
|
||||
// - write lock to ClientInfo
|
||||
func UnsubscribeAll(client *ClientInfo) {
|
||||
if StopAcceptingConnections {
|
||||
return // no need to remove from a high-contention list when the server is closing
|
||||
}
|
||||
|
||||
GlobalSubscriptionLock.Lock()
|
||||
RemoveFromSliceCl(&GlobalSubscriptionInfo, client)
|
||||
GlobalSubscriptionLock.Unlock()
|
||||
|
||||
ChatSubscriptionLock.RLock()
|
||||
client.Mutex.Lock()
|
||||
for _, v := range client.CurrentChannels {
|
||||
list := ChatSubscriptionInfo[v]
|
||||
if list != nil {
|
||||
list.Lock()
|
||||
RemoveFromSliceC(&list.Members, client.MessageChannel)
|
||||
list.Unlock()
|
||||
}
|
||||
}
|
||||
client.CurrentChannels = nil
|
||||
client.Mutex.Unlock()
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
func unsubscribeAllClients() {
|
||||
GlobalSubscriptionLock.Lock()
|
||||
GlobalSubscriptionInfo = nil
|
||||
GlobalSubscriptionLock.Unlock()
|
||||
ChatSubscriptionLock.Lock()
|
||||
ChatSubscriptionInfo = make(map[string]*SubscriberList)
|
||||
ChatSubscriptionLock.Unlock()
|
||||
}
|
||||
|
||||
const ReapingDelay = 1 * time.Minute
|
||||
|
||||
// Checks ChatSubscriptionInfo for entries with no subscribers every ReapingDelay.
|
||||
// is_init_func
|
||||
func pubsubJanitor() {
|
||||
for {
|
||||
time.Sleep(ReapingDelay)
|
||||
pubsubJanitor_do()
|
||||
}
|
||||
}
|
||||
|
||||
func pubsubJanitor_do() {
|
||||
var cleanedUp = make([]string, 0, 6)
|
||||
ChatSubscriptionLock.Lock()
|
||||
for key, val := range ChatSubscriptionInfo {
|
||||
if val == nil || len(val.Members) == 0 {
|
||||
delete(ChatSubscriptionInfo, key)
|
||||
cleanedUp = append(cleanedUp, key)
|
||||
}
|
||||
}
|
||||
ChatSubscriptionLock.Unlock()
|
||||
|
||||
if len(cleanedUp) != 0 {
|
||||
err := Backend.SendCleanupTopicsNotice(cleanedUp)
|
||||
if err != nil {
|
||||
log.Println("error reporting cleaned subs:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a channel to the subscriptions while holding a read-lock to the map.
|
||||
// Locks:
|
||||
// - ALREADY HOLDING a read-lock to the 'which' top-level map via the rlocker object
|
||||
|
@ -82,6 +201,14 @@ func _subscribeWhileRlocked(channelName string, value chan<- ClientMessage) {
|
|||
list.Members = []chan<- ClientMessage{value} // Create it populated, to avoid reaper
|
||||
ChatSubscriptionInfo[channelName] = list
|
||||
ChatSubscriptionLock.Unlock()
|
||||
|
||||
go func(topic string) {
|
||||
err := Backend.SendNewTopicNotice(topic)
|
||||
if err != nil {
|
||||
log.Println("error reporting new sub:", err)
|
||||
}
|
||||
}(channelName)
|
||||
|
||||
ChatSubscriptionLock.RLock()
|
||||
} else {
|
||||
list.Lock()
|
||||
|
@ -89,80 +216,3 @@ func _subscribeWhileRlocked(channelName string, value chan<- ClientMessage) {
|
|||
list.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func SubscribeGlobal(client *ClientInfo) {
|
||||
GlobalSubscriptionInfo.Lock()
|
||||
AddToSliceC(&GlobalSubscriptionInfo.Members, client.MessageChannel)
|
||||
GlobalSubscriptionInfo.Unlock()
|
||||
}
|
||||
|
||||
func SubscribeChat(client *ClientInfo, channelName string) {
|
||||
ChatSubscriptionLock.RLock()
|
||||
_subscribeWhileRlocked(channelName, client.MessageChannel)
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
func unsubscribeAllClients() {
|
||||
GlobalSubscriptionInfo.Lock()
|
||||
GlobalSubscriptionInfo.Members = nil
|
||||
GlobalSubscriptionInfo.Unlock()
|
||||
ChatSubscriptionLock.Lock()
|
||||
ChatSubscriptionInfo = make(map[string]*SubscriberList)
|
||||
ChatSubscriptionLock.Unlock()
|
||||
}
|
||||
|
||||
// Unsubscribe the client from all channels, AND clear the CurrentChannels / WatchingChannels fields.
|
||||
// Locks:
|
||||
// - read lock to top-level maps
|
||||
// - write lock to SubscriptionInfos
|
||||
// - write lock to ClientInfo
|
||||
func UnsubscribeAll(client *ClientInfo) {
|
||||
client.Mutex.Lock()
|
||||
client.PendingSubscriptionsBacklog = nil
|
||||
client.PendingSubscriptionsBacklog = nil
|
||||
client.Mutex.Unlock()
|
||||
|
||||
GlobalSubscriptionInfo.Lock()
|
||||
RemoveFromSliceC(&GlobalSubscriptionInfo.Members, client.MessageChannel)
|
||||
GlobalSubscriptionInfo.Unlock()
|
||||
|
||||
ChatSubscriptionLock.RLock()
|
||||
client.Mutex.Lock()
|
||||
for _, v := range client.CurrentChannels {
|
||||
list := ChatSubscriptionInfo[v]
|
||||
if list != nil {
|
||||
list.Lock()
|
||||
RemoveFromSliceC(&list.Members, client.MessageChannel)
|
||||
list.Unlock()
|
||||
}
|
||||
}
|
||||
client.CurrentChannels = nil
|
||||
client.Mutex.Unlock()
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
func UnsubscribeSingleChat(client *ClientInfo, channelName string) {
|
||||
ChatSubscriptionLock.RLock()
|
||||
list := ChatSubscriptionInfo[channelName]
|
||||
list.Lock()
|
||||
RemoveFromSliceC(&list.Members, client.MessageChannel)
|
||||
list.Unlock()
|
||||
ChatSubscriptionLock.RUnlock()
|
||||
}
|
||||
|
||||
const ReapingDelay = 120 * time.Minute
|
||||
|
||||
// Checks ChatSubscriptionInfo for entries with no subscribers every ReapingDelay.
|
||||
// Started from SetupServer().
|
||||
func deadChannelReaper() {
|
||||
for {
|
||||
time.Sleep(ReapingDelay)
|
||||
ChatSubscriptionLock.Lock()
|
||||
for key, val := range ChatSubscriptionInfo {
|
||||
if len(val.Members) == 0 {
|
||||
ChatSubscriptionInfo[key] = nil
|
||||
}
|
||||
}
|
||||
ChatSubscriptionLock.Unlock()
|
||||
}
|
||||
}
|
|
@ -1,172 +1,21 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/satori/go.uuid"
|
||||
"golang.org/x/net/websocket"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
func TCountOpenFDs() uint64 {
|
||||
ary, _ := ioutil.ReadDir(fmt.Sprintf("/proc/%d/fd", os.Getpid()))
|
||||
return uint64(len(ary))
|
||||
}
|
||||
|
||||
const IgnoreReceivedArguments = 1 + 2i
|
||||
|
||||
func TReceiveExpectedMessage(tb testing.TB, conn *websocket.Conn, messageId int, command Command, arguments interface{}) (ClientMessage, bool) {
|
||||
var msg ClientMessage
|
||||
var fail bool
|
||||
err := FFZCodec.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return msg, false
|
||||
}
|
||||
if msg.MessageID != messageId {
|
||||
tb.Error("Message ID was wrong. Expected", messageId, ", got", msg.MessageID, ":", msg)
|
||||
fail = true
|
||||
}
|
||||
if msg.Command != command {
|
||||
tb.Error("Command was wrong. Expected", command, ", got", msg.Command, ":", msg)
|
||||
fail = true
|
||||
}
|
||||
if arguments != IgnoreReceivedArguments {
|
||||
if arguments == nil {
|
||||
if msg.origArguments != "" {
|
||||
tb.Error("Arguments are wrong. Expected", arguments, ", got", msg.Arguments, ":", msg)
|
||||
}
|
||||
} else {
|
||||
argBytes, _ := json.Marshal(arguments)
|
||||
if msg.origArguments != string(argBytes) {
|
||||
tb.Error("Arguments are wrong. Expected", arguments, ", got", msg.Arguments, ":", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg, !fail
|
||||
}
|
||||
|
||||
func TSendMessage(tb testing.TB, conn *websocket.Conn, messageId int, command Command, arguments interface{}) bool {
|
||||
err := FFZCodec.Send(conn, ClientMessage{MessageID: messageId, Command: command, Arguments: arguments})
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TSealForSavePubMsg(tb testing.TB, cmd Command, channel string, arguments interface{}, deleteMode bool) (url.Values, error) {
|
||||
form := url.Values{}
|
||||
form.Set("cmd", string(cmd))
|
||||
argsBytes, err := json.Marshal(arguments)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
form.Set("args", string(argsBytes))
|
||||
form.Set("channel", channel)
|
||||
if deleteMode {
|
||||
form.Set("delete", "1")
|
||||
}
|
||||
form.Set("time", time.Now().Format(time.UnixDate))
|
||||
|
||||
sealed, err := SealRequest(form)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
func TCheckResponse(tb testing.TB, resp *http.Response, expected string) bool {
|
||||
var failed bool
|
||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
respStr := string(respBytes)
|
||||
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
tb.Error("Publish failed: ", resp.StatusCode, respStr)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if respStr != expected {
|
||||
tb.Errorf("Got wrong response from server. Expected: '%s' Got: '%s'", expected, respStr)
|
||||
failed = true
|
||||
}
|
||||
return !failed
|
||||
}
|
||||
|
||||
type TURLs struct {
|
||||
Websocket string
|
||||
Origin string
|
||||
PubMsg string
|
||||
SavePubMsg string // update_and_pub
|
||||
}
|
||||
|
||||
func TGetUrls(testserver *httptest.Server) TURLs {
|
||||
addr := testserver.Listener.Addr().String()
|
||||
return TURLs{
|
||||
Websocket: fmt.Sprintf("ws://%s/", addr),
|
||||
Origin: fmt.Sprintf("http://%s", addr),
|
||||
PubMsg: fmt.Sprintf("http://%s/pub_msg", addr),
|
||||
SavePubMsg: fmt.Sprintf("http://%s/update_and_pub", addr),
|
||||
}
|
||||
}
|
||||
|
||||
func TSetup(testserver **httptest.Server, urls *TURLs) {
|
||||
DumpCache()
|
||||
|
||||
conf := &ConfigFile{
|
||||
ServerId: 20,
|
||||
UseSSL: false,
|
||||
SocketOrigin: "localhost:2002",
|
||||
BannerHTML: `
|
||||
<!DOCTYPE html>
|
||||
<title>CatBag</title>
|
||||
<link rel="stylesheet" href="//cdn.frankerfacez.com/script/catbag.css">
|
||||
<div id="container">
|
||||
<div id="zf0"></div><div id="zf1"></div><div id="zf2"></div>
|
||||
<div id="zf3"></div><div id="zf4"></div><div id="zf5"></div>
|
||||
<div id="zf6"></div><div id="zf7"></div><div id="zf8"></div>
|
||||
<div id="zf9"></div><div id="catbag"></div>
|
||||
<div id="bottom">
|
||||
A <a href="http://www.frankerfacez.com/">FrankerFaceZ</a> Service
|
||||
— CatBag by <a href="http://www.twitch.tv/wolsk">Wolsk</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
OurPublicKey: []byte{176, 149, 72, 209, 35, 42, 110, 220, 22, 236, 212, 129, 213, 199, 1, 227, 185, 167, 150, 159, 117, 202, 164, 100, 9, 107, 45, 141, 122, 221, 155, 73},
|
||||
OurPrivateKey: []byte{247, 133, 147, 194, 70, 240, 211, 216, 223, 16, 241, 253, 120, 14, 198, 74, 237, 180, 89, 33, 146, 146, 140, 58, 88, 160, 2, 246, 112, 35, 239, 87},
|
||||
BackendPublicKey: []byte{19, 163, 37, 157, 50, 139, 193, 85, 229, 47, 166, 21, 153, 231, 31, 133, 41, 158, 8, 53, 73, 0, 113, 91, 13, 181, 131, 248, 176, 18, 1, 107},
|
||||
}
|
||||
gconfig = conf
|
||||
SetupBackend(conf)
|
||||
|
||||
if testserver != nil {
|
||||
serveMux := http.NewServeMux()
|
||||
SetupServerAndHandle(conf, nil, serveMux)
|
||||
|
||||
tserv := httptest.NewUnstartedServer(serveMux)
|
||||
*testserver = tserv
|
||||
tserv.Start()
|
||||
if urls != nil {
|
||||
*urls = TGetUrls(tserv)
|
||||
}
|
||||
}
|
||||
}
|
||||
const TestOrigin = "http://www.twitch.tv"
|
||||
|
||||
func TestSubscriptionAndPublish(t *testing.T) {
|
||||
var doneWg sync.WaitGroup
|
||||
|
@ -184,19 +33,30 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
const TestData3 = false
|
||||
var TestData4 = []interface{}{"str1", "str2", "str3"}
|
||||
|
||||
ServerInitiatedCommands[TestCommandChan] = PushCommandCacheInfo{CacheTypeLastOnly, MsgTargetTypeChat}
|
||||
ServerInitiatedCommands[TestCommandMulti] = PushCommandCacheInfo{CacheTypeTimestamps, MsgTargetTypeMultichat}
|
||||
ServerInitiatedCommands[TestCommandGlobal] = PushCommandCacheInfo{CacheTypeTimestamps, MsgTargetTypeGlobal}
|
||||
t.Log("TestSubscriptionAndPublish")
|
||||
|
||||
var server *httptest.Server
|
||||
var urls TURLs
|
||||
TSetup(&server, &urls)
|
||||
|
||||
var backendExpected = NewTBackendRequestChecker(t,
|
||||
TExpectedBackendRequest{200, bPathAnnounceStartup, &url.Values{"startup": []string{"1"}}, "", nil},
|
||||
TExpectedBackendRequest{200, bPathAddTopic, &url.Values{"channels": []string{TestChannelName1}, "added": []string{"t"}}, "ok", nil},
|
||||
TExpectedBackendRequest{200, bPathAddTopic, &url.Values{"channels": []string{TestChannelName2}, "added": []string{"t"}}, "ok", nil},
|
||||
TExpectedBackendRequest{200, bPathAddTopic, &url.Values{"channels": []string{TestChannelName3}, "added": []string{"t"}}, "ok", nil},
|
||||
)
|
||||
server, _, urls = TSetup(SetupWantSocketServer|SetupWantBackendServer|SetupWantURLs, backendExpected)
|
||||
|
||||
defer server.CloseClientConnections()
|
||||
defer unsubscribeAllClients()
|
||||
defer backendExpected.Close()
|
||||
|
||||
var conn *websocket.Conn
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
var headers http.Header = make(http.Header)
|
||||
headers.Set("Origin", TestOrigin)
|
||||
|
||||
// client 1: sub ch1, ch2
|
||||
// client 2: sub ch1, ch3
|
||||
// client 3: sub none
|
||||
|
@ -204,15 +64,18 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
// msg 1: ch1
|
||||
// msg 2: ch2, ch3
|
||||
// msg 3: chEmpty
|
||||
// msg 4: global
|
||||
// msg 4: global uncached
|
||||
|
||||
// Client 1
|
||||
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin)
|
||||
conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// both origins need testing
|
||||
headers.Set("Origin", "https://www.twitch.tv")
|
||||
|
||||
doneWg.Add(1)
|
||||
readyWg.Add(1)
|
||||
go func(conn *websocket.Conn) {
|
||||
|
@ -236,13 +99,14 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
}(conn)
|
||||
|
||||
// Client 2
|
||||
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin)
|
||||
conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
doneWg.Add(1)
|
||||
readyWg.Wait() // enforce ordering
|
||||
readyWg.Add(1)
|
||||
go func(conn *websocket.Conn) {
|
||||
TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()})
|
||||
|
@ -265,13 +129,15 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
}(conn)
|
||||
|
||||
// Client 3
|
||||
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin)
|
||||
conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
doneWg.Add(1)
|
||||
readyWg.Wait() // enforce ordering
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
readyWg.Add(1)
|
||||
go func(conn *websocket.Conn) {
|
||||
TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()})
|
||||
|
@ -291,7 +157,6 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
readyWg.Wait()
|
||||
|
||||
var form url.Values
|
||||
var resp *http.Response
|
||||
|
||||
// Publish message 1 - should go to clients 1, 2
|
||||
|
||||
|
@ -300,7 +165,7 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
t.FailNow()
|
||||
}
|
||||
resp, err = http.PostForm(urls.SavePubMsg, form)
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(2)) {
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(2), "pub msg 1") {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
|
@ -311,7 +176,7 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
t.FailNow()
|
||||
}
|
||||
resp, err = http.PostForm(urls.SavePubMsg, form)
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(2)) {
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(2), "pub msg 2") {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
|
@ -322,23 +187,23 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
t.FailNow()
|
||||
}
|
||||
resp, err = http.PostForm(urls.SavePubMsg, form)
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(0)) {
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(0), "pub msg 3") {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
// Publish message 4 - should go to clients 1, 2, 3
|
||||
|
||||
form, err = TSealForSavePubMsg(t, TestCommandGlobal, "", TestData4, false)
|
||||
form, err = TSealForUncachedPubMsg(t, TestCommandGlobal, "", TestData4, "global", false)
|
||||
if err != nil {
|
||||
t.FailNow()
|
||||
}
|
||||
resp, err = http.PostForm(urls.SavePubMsg, form)
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(3)) {
|
||||
resp, err = http.PostForm(urls.UncachedPubMsg, form)
|
||||
if !TCheckResponse(t, resp, strconv.Itoa(3), "pub msg 4") {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
// Start client 4
|
||||
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin)
|
||||
conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
|
@ -367,6 +232,113 @@ func TestSubscriptionAndPublish(t *testing.T) {
|
|||
|
||||
doneWg.Wait()
|
||||
server.Close()
|
||||
|
||||
clientCount := readCurrentHLL()
|
||||
if clientCount < 3 || clientCount > 5 {
|
||||
t.Error("clientCount outside acceptable range: expected 4, got ", clientCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedCommands(t *testing.T) {
|
||||
var doneWg sync.WaitGroup
|
||||
var readyWg sync.WaitGroup
|
||||
|
||||
const TestCommandNeedsAuth = "needsauth"
|
||||
const TestRequestData = "123456"
|
||||
const TestRequestDataJSON = "\"" + TestRequestData + "\""
|
||||
const TestReplyData = "success"
|
||||
const TestUsername = "sirstendec"
|
||||
|
||||
var server *httptest.Server
|
||||
var urls TURLs
|
||||
|
||||
t.Log("TestRestrictedCommands")
|
||||
|
||||
var backendExpected = NewTBackendRequestChecker(t,
|
||||
TExpectedBackendRequest{200, bPathAnnounceStartup, &url.Values{"startup": []string{"1"}}, "", nil},
|
||||
TExpectedBackendRequest{401, fmt.Sprintf("%s%s", bPathOtherCommand, TestCommandNeedsAuth), &url.Values{"authenticated": []string{"0"}, "username": []string{""}, "clientData": []string{TestRequestDataJSON}}, "", nil},
|
||||
TExpectedBackendRequest{401, fmt.Sprintf("%s%s", bPathOtherCommand, TestCommandNeedsAuth), &url.Values{"authenticated": []string{"0"}, "username": []string{TestUsername}, "clientData": []string{TestRequestDataJSON}}, "", nil},
|
||||
TExpectedBackendRequest{200, fmt.Sprintf("%s%s", bPathOtherCommand, TestCommandNeedsAuth), &url.Values{"authenticated": []string{"1"}, "username": []string{TestUsername}, "clientData": []string{TestRequestDataJSON}}, fmt.Sprintf("\"%s\"", TestReplyData), nil},
|
||||
)
|
||||
server, _, urls = TSetup(SetupWantSocketServer|SetupWantBackendServer|SetupWantURLs, backendExpected)
|
||||
|
||||
defer server.CloseClientConnections()
|
||||
defer unsubscribeAllClients()
|
||||
defer backendExpected.Close()
|
||||
|
||||
var conn *websocket.Conn
|
||||
var err error
|
||||
var challengeChan = make(chan string)
|
||||
|
||||
var headers http.Header = make(http.Header)
|
||||
headers.Set("Origin", TestOrigin)
|
||||
|
||||
// Client 1
|
||||
conn, _, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
doneWg.Add(1)
|
||||
readyWg.Add(1)
|
||||
go func(conn *websocket.Conn) {
|
||||
defer doneWg.Done()
|
||||
defer conn.Close()
|
||||
TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()})
|
||||
TReceiveExpectedMessage(t, conn, 1, SuccessCommand, IgnoreReceivedArguments)
|
||||
TSendMessage(t, conn, 2, ReadyCommand, 0)
|
||||
TReceiveExpectedMessage(t, conn, 2, SuccessCommand, nil)
|
||||
|
||||
// Should get immediate refusal because no username set
|
||||
TSendMessage(t, conn, 3, TestCommandNeedsAuth, TestRequestData)
|
||||
TReceiveExpectedMessage(t, conn, 3, ErrorCommand, AuthorizationNeededError)
|
||||
|
||||
// Set a username
|
||||
TSendMessage(t, conn, 4, SetUserCommand, TestUsername)
|
||||
TReceiveExpectedMessage(t, conn, 4, SuccessCommand, nil)
|
||||
|
||||
// Should get authorization prompt
|
||||
TSendMessage(t, conn, 5, TestCommandNeedsAuth, TestRequestData)
|
||||
readyWg.Done()
|
||||
msg, success := TReceiveExpectedMessage(t, conn, -1, AuthorizeCommand, IgnoreReceivedArguments)
|
||||
if !success {
|
||||
t.Error("recieve authorize command failed, cannot continue")
|
||||
return
|
||||
}
|
||||
challenge, err := msg.ArgumentsAsString()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
challengeChan <- challenge // mocked: sending challenge to IRC server, IRC server sends challenge to socket server
|
||||
|
||||
TReceiveExpectedMessage(t, conn, 5, SuccessCommand, TestReplyData)
|
||||
}(conn)
|
||||
|
||||
readyWg.Wait()
|
||||
|
||||
challenge := <-challengeChan
|
||||
PendingAuthLock.Lock()
|
||||
found := false
|
||||
for _, v := range PendingAuths {
|
||||
if conn.LocalAddr().String() == v.Client.RemoteAddr.String() {
|
||||
found = true
|
||||
if v.Challenge != challenge {
|
||||
t.Error("Challenge in array was not what client got")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
PendingAuthLock.Unlock()
|
||||
if !found {
|
||||
t.Fatal("Did not find authorization challenge in the pending auths array")
|
||||
}
|
||||
|
||||
submitAuth(TestUsername, challenge)
|
||||
|
||||
doneWg.Wait()
|
||||
server.Close()
|
||||
}
|
||||
|
||||
func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
|
||||
|
@ -396,12 +368,15 @@ func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
|
|||
|
||||
var server *httptest.Server
|
||||
var urls TURLs
|
||||
TSetup(&server, &urls)
|
||||
server, _, urls = TSetup(SetupWantSocketServer|SetupWantURLs, nil)
|
||||
defer unsubscribeAllClients()
|
||||
|
||||
var headers http.Header = make(http.Header)
|
||||
headers.Set("Origin", TestOrigin)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
conn, err := websocket.Dial(urls.Websocket, "", urls.Origin)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(urls.Websocket, headers)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
break
|
||||
|
@ -427,7 +402,7 @@ func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
|
|||
readyWg.Wait()
|
||||
|
||||
fmt.Println("publishing...")
|
||||
if PublishToChat(TestChannelName, message) != b.N {
|
||||
if PublishToChannel(TestChannelName, message) != b.N {
|
||||
b.Error("not enough sent")
|
||||
server.CloseClientConnections()
|
||||
panic("halting test instead of waiting")
|
357
socketserver/server/testinfra_test.go
Normal file
357
socketserver/server/testinfra_test.go
Normal file
|
@ -0,0 +1,357 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
SetupWantSocketServer = 1 << iota
|
||||
SetupWantBackendServer
|
||||
SetupWantURLs
|
||||
)
|
||||
const SetupNoServers = 0
|
||||
|
||||
var signalCatch sync.Once
|
||||
|
||||
func TSetup(flags int, backendChecker *TBackendRequestChecker) (socketserver *httptest.Server, backend *httptest.Server, urls TURLs) {
|
||||
signalCatch.Do(func() {
|
||||
go dumpStackOnCtrlZ()
|
||||
})
|
||||
|
||||
DumpBacklogData()
|
||||
|
||||
ioutil.WriteFile("index.html", []byte(`
|
||||
<!DOCTYPE html>
|
||||
<title>CatBag</title>
|
||||
<link rel="stylesheet" href="//cdn.frankerfacez.com/script/catbag.css">
|
||||
<div id="container">
|
||||
<div id="zf0"></div><div id="zf1"></div><div id="zf2"></div>
|
||||
<div id="zf3"></div><div id="zf4"></div><div id="zf5"></div>
|
||||
<div id="zf6"></div><div id="zf7"></div><div id="zf8"></div>
|
||||
<div id="zf9"></div><div id="catbag"></div>
|
||||
<div id="bottom">
|
||||
A <a href="http://www.frankerfacez.com/">FrankerFaceZ</a> Service
|
||||
— CatBag by <a href="http://www.twitch.tv/wolsk">Wolsk</a>
|
||||
</div>
|
||||
</div>`), 0644)
|
||||
|
||||
conf := &ConfigFile{
|
||||
ServerID: 20,
|
||||
UseSSL: false,
|
||||
OurPublicKey: []byte{176, 149, 72, 209, 35, 42, 110, 220, 22, 236, 212, 129, 213, 199, 1, 227, 185, 167, 150, 159, 117, 202, 164, 100, 9, 107, 45, 141, 122, 221, 155, 73},
|
||||
OurPrivateKey: []byte{247, 133, 147, 194, 70, 240, 211, 216, 223, 16, 241, 253, 120, 14, 198, 74, 237, 180, 89, 33, 146, 146, 140, 58, 88, 160, 2, 246, 112, 35, 239, 87},
|
||||
BackendPublicKey: []byte{19, 163, 37, 157, 50, 139, 193, 85, 229, 47, 166, 21, 153, 231, 31, 133, 41, 158, 8, 53, 73, 0, 113, 91, 13, 181, 131, 248, 176, 18, 1, 107},
|
||||
}
|
||||
|
||||
if flags&SetupWantBackendServer != 0 {
|
||||
backend = httptest.NewServer(backendChecker)
|
||||
conf.BackendURL = fmt.Sprintf("http://%s", backend.Listener.Addr().String())
|
||||
}
|
||||
|
||||
Configuration = conf
|
||||
setupBackend(conf)
|
||||
|
||||
if flags&SetupWantSocketServer != 0 {
|
||||
serveMux := http.NewServeMux()
|
||||
SetupServerAndHandle(conf, serveMux)
|
||||
dumpUniqueUsers()
|
||||
|
||||
socketserver = httptest.NewServer(serveMux)
|
||||
}
|
||||
|
||||
if flags&SetupWantURLs != 0 {
|
||||
urls = TGetUrls(socketserver, backend)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type TBC interface {
|
||||
Error(args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
const MethodIsPost = "POST"
|
||||
|
||||
type TExpectedBackendRequest struct {
|
||||
ResponseCode int
|
||||
Path string
|
||||
// Method string // always POST
|
||||
PostForm *url.Values
|
||||
Response string
|
||||
ResponseHeaders http.Header
|
||||
}
|
||||
|
||||
func (er *TExpectedBackendRequest) String() string {
|
||||
if MethodIsPost == "" {
|
||||
return er.Path
|
||||
}
|
||||
return fmt.Sprint("%s %s: %s", MethodIsPost, er.Path, er.PostForm.Encode())
|
||||
}
|
||||
|
||||
type TBackendRequestChecker struct {
|
||||
ExpectedRequests []TExpectedBackendRequest
|
||||
|
||||
currentRequest int
|
||||
tb TBC
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewTBackendRequestChecker(tb TBC, urls ...TExpectedBackendRequest) *TBackendRequestChecker {
|
||||
return &TBackendRequestChecker{ExpectedRequests: urls, tb: tb, currentRequest: 0}
|
||||
}
|
||||
|
||||
func (backend *TBackendRequestChecker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
backend.mutex.Lock()
|
||||
defer backend.mutex.Unlock()
|
||||
|
||||
if r.Method != MethodIsPost {
|
||||
backend.tb.Errorf("Bad backend request: was not a POST. %v", r)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
unsealedForm, err := Backend.UnsealRequest(r.PostForm)
|
||||
if err != nil {
|
||||
backend.tb.Errorf("Failed to unseal backend request: %v", err)
|
||||
}
|
||||
|
||||
if backend.currentRequest >= len(backend.ExpectedRequests) {
|
||||
backend.tb.Errorf("Unexpected backend request: %s %s: %s", r.Method, r.URL, unsealedForm)
|
||||
return
|
||||
}
|
||||
|
||||
cur := backend.ExpectedRequests[backend.currentRequest]
|
||||
backend.currentRequest++
|
||||
|
||||
headers := w.Header()
|
||||
for k, v := range cur.ResponseHeaders {
|
||||
if len(v) == 1 {
|
||||
headers.Set(k, v[0])
|
||||
} else if len(v) == 0 {
|
||||
headers.Del(k)
|
||||
} else {
|
||||
for _, hv := range v {
|
||||
headers.Add(k, hv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
w.WriteHeader(cur.ResponseCode)
|
||||
if cur.Response != "" {
|
||||
w.Write([]byte(cur.Response))
|
||||
}
|
||||
}()
|
||||
|
||||
if cur.Path != "" {
|
||||
if r.URL.Path != cur.Path {
|
||||
backend.tb.Errorf("Bad backend request. Expected %v, got %s %s", cur, r.Method, r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if cur.PostForm != nil {
|
||||
anyErr := TcompareForms(backend.tb, "Different form contents", *cur.PostForm, unsealedForm)
|
||||
if anyErr {
|
||||
backend.tb.Errorf("...in %s %s: %s", r.Method, r.URL, unsealedForm.Encode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (backend *TBackendRequestChecker) Close() error {
|
||||
if backend.currentRequest < len(backend.ExpectedRequests) {
|
||||
backend.tb.Errorf("Not all requests sent, got %d out of %d", backend.currentRequest, len(backend.ExpectedRequests))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TcompareForms(tb TBC, ctx string, expectedForm, gotForm url.Values) (anyErrors bool) {
|
||||
for k, expVal := range expectedForm {
|
||||
gotVal, ok := gotForm[k]
|
||||
if !ok {
|
||||
tb.Errorf("%s: Form[%s]: Expected %v, (got nothing)", ctx, k, expVal)
|
||||
anyErrors = true
|
||||
continue
|
||||
}
|
||||
if len(expVal) != len(gotVal) {
|
||||
tb.Errorf("%s: Form[%s]: Expected %d%v, Got %d%v", ctx, k, len(expVal), expVal, len(gotVal), gotVal)
|
||||
anyErrors = true
|
||||
continue
|
||||
}
|
||||
for i, el := range expVal {
|
||||
if gotVal[i] != el {
|
||||
tb.Errorf("%s: Form[%s][%d]: Expected %s, Got %s", ctx, k, i, el, gotVal[i])
|
||||
anyErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, gotVal := range gotForm {
|
||||
_, ok := expectedForm[k]
|
||||
if !ok {
|
||||
tb.Errorf("%s: Form[%s]: (expected nothing), Got %v", ctx, k, gotVal)
|
||||
anyErrors = true
|
||||
}
|
||||
}
|
||||
return anyErrors
|
||||
}
|
||||
|
||||
func TCountOpenFDs() uint64 {
|
||||
ary, _ := ioutil.ReadDir(fmt.Sprintf("/proc/%d/fd", os.Getpid()))
|
||||
return uint64(len(ary))
|
||||
}
|
||||
|
||||
const IgnoreReceivedArguments = 1 + 2i
|
||||
|
||||
func TReceiveExpectedMessage(tb testing.TB, conn *websocket.Conn, messageID int, command Command, arguments interface{}) (ClientMessage, bool) {
|
||||
var msg ClientMessage
|
||||
var fail bool
|
||||
messageType, packet, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return msg, false
|
||||
}
|
||||
if messageType != websocket.TextMessage {
|
||||
tb.Error("got non-text message", packet)
|
||||
return msg, false
|
||||
}
|
||||
|
||||
err = UnmarshalClientMessage(packet, messageType, &msg)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return msg, false
|
||||
}
|
||||
if msg.MessageID != messageID {
|
||||
tb.Error("Message ID was wrong. Expected", messageID, ", got", msg.MessageID, ":", msg)
|
||||
fail = true
|
||||
}
|
||||
if msg.Command != command {
|
||||
tb.Error("Command was wrong. Expected", command, ", got", msg.Command, ":", msg)
|
||||
fail = true
|
||||
}
|
||||
if arguments != IgnoreReceivedArguments {
|
||||
if arguments == nil {
|
||||
if msg.origArguments != "" {
|
||||
tb.Error("Arguments are wrong. Expected", arguments, ", got", msg.Arguments, ":", msg)
|
||||
}
|
||||
} else {
|
||||
argBytes, _ := json.Marshal(arguments)
|
||||
if msg.origArguments != string(argBytes) {
|
||||
tb.Error("Arguments are wrong. Expected", arguments, ", got", msg.Arguments, ":", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg, !fail
|
||||
}
|
||||
|
||||
func TSendMessage(tb testing.TB, conn *websocket.Conn, messageID int, command Command, arguments interface{}) bool {
|
||||
SendMessage(conn, ClientMessage{MessageID: messageID, Command: command, Arguments: arguments})
|
||||
return true
|
||||
}
|
||||
|
||||
func TSealForSavePubMsg(tb testing.TB, cmd Command, channel string, arguments interface{}, deleteMode bool) (url.Values, error) {
|
||||
form := url.Values{}
|
||||
form.Set("cmd", string(cmd))
|
||||
argsBytes, err := json.Marshal(arguments)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
form.Set("args", string(argsBytes))
|
||||
form.Set("channel", channel)
|
||||
if deleteMode {
|
||||
form.Set("delete", "1")
|
||||
}
|
||||
form.Set("time", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
|
||||
sealed, err := Backend.SealRequest(form)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
func TSealForUncachedPubMsg(tb testing.TB, cmd Command, channel string, arguments interface{}, scope string, deleteMode bool) (url.Values, error) {
|
||||
form := url.Values{}
|
||||
form.Set("cmd", string(cmd))
|
||||
argsBytes, err := json.Marshal(arguments)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
form.Set("args", string(argsBytes))
|
||||
form.Set("channel", channel)
|
||||
if deleteMode {
|
||||
form.Set("delete", "1")
|
||||
}
|
||||
form.Set("time", time.Now().Format(time.UnixDate))
|
||||
form.Set("scope", scope)
|
||||
|
||||
sealed, err := Backend.SealRequest(form)
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
func TCheckResponse(tb testing.TB, resp *http.Response, expected string, desc string) bool {
|
||||
var failed bool
|
||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
respStr := string(respBytes)
|
||||
|
||||
if err != nil {
|
||||
tb.Error(err)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
tb.Error("Publish failed: ", resp.StatusCode, respStr)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if respStr != expected {
|
||||
tb.Errorf("Got wrong response from server. %s Expected: '%s' Got: '%s'", desc, expected, respStr)
|
||||
failed = true
|
||||
}
|
||||
return !failed
|
||||
}
|
||||
|
||||
type TURLs struct {
|
||||
Websocket string
|
||||
Origin string
|
||||
UncachedPubMsg string // uncached_pub
|
||||
SavePubMsg string // cached_pub
|
||||
}
|
||||
|
||||
func TGetUrls(socketserver *httptest.Server, backend *httptest.Server) TURLs {
|
||||
addr := socketserver.Listener.Addr().String()
|
||||
return TURLs{
|
||||
Websocket: fmt.Sprintf("ws://%s/", addr),
|
||||
Origin: fmt.Sprintf("http://%s", addr),
|
||||
UncachedPubMsg: fmt.Sprintf("http://%s/uncached_pub", addr),
|
||||
SavePubMsg: fmt.Sprintf("http://%s/cached_pub", addr),
|
||||
}
|
||||
}
|
||||
|
||||
func TCheckHLLValue(tb testing.TB, expected uint64, actual uint64) {
|
||||
high := uint64(float64(expected) * 1.05)
|
||||
low := uint64(float64(expected) * 0.95)
|
||||
if actual < low || actual > high {
|
||||
tb.Errorf("Count outside expected range. Expected %d, Got %d", expected, actual)
|
||||
}
|
||||
}
|
12
socketserver/server/tickspersecond.go
Normal file
12
socketserver/server/tickspersecond.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package server
|
||||
|
||||
// #include <unistd.h>
|
||||
// long get_ticks_per_second() {
|
||||
// return sysconf(_SC_CLK_TCK);
|
||||
// }
|
||||
//import "C"
|
||||
|
||||
// note: this seems to add 0.1s to compile time on my machine
|
||||
//var ticksPerSecond = int(C.get_ticks_per_second())
|
||||
|
||||
var ticksPerSecond = 100
|
153
socketserver/server/types.go
Normal file
153
socketserver/server/types.go
Normal file
|
@ -0,0 +1,153 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
const NegativeOne = ^uint64(0)
|
||||
|
||||
var AnonymousClientID = uuid.FromStringOrNil("683b45e4-f853-4c45-bf96-7d799cc93e34")
|
||||
|
||||
type ConfigFile struct {
|
||||
// Numeric server id known to the backend
|
||||
ServerID int
|
||||
// Address to bind the HTTP server to on startup.
|
||||
ListenAddr string
|
||||
// Address to bind the TLS server to on startup.
|
||||
SSLListenAddr string
|
||||
// URL to the backend server
|
||||
BackendURL string
|
||||
|
||||
// Minimum memory to accept a new connection
|
||||
MinMemoryKBytes uint64
|
||||
// Maximum # of clients that can be connected. 0 to disable.
|
||||
MaxClientCount uint64
|
||||
|
||||
// SSL/TLS
|
||||
// Enable the use of SSL.
|
||||
UseSSL bool
|
||||
// Path to certificate file.
|
||||
SSLCertificateFile string
|
||||
// Path to key file.
|
||||
SSLKeyFile string
|
||||
|
||||
UseESLogStashing bool
|
||||
ESServer string
|
||||
ESIndexPrefix string
|
||||
ESHostName string
|
||||
|
||||
// Nacl keys
|
||||
OurPrivateKey []byte
|
||||
OurPublicKey []byte
|
||||
BackendPublicKey []byte
|
||||
|
||||
// Request username validation from all new clients.
|
||||
SendAuthToNewClients bool
|
||||
}
|
||||
|
||||
type ClientMessage struct {
|
||||
// Message ID. Increments by 1 for each message sent from the client.
|
||||
// When replying to a command, the message ID must be echoed.
|
||||
// When sending a server-initiated message, this is -1.
|
||||
MessageID int `json:"m"`
|
||||
// The command that the client wants from the server.
|
||||
// When sent from the server, the literal string 'True' indicates success.
|
||||
// Before sending, a blank Command will be converted into SuccessCommand.
|
||||
Command Command `json:"c"`
|
||||
// Result of json.Unmarshal on the third field send from the client
|
||||
Arguments interface{} `json:"a"`
|
||||
|
||||
origArguments string
|
||||
}
|
||||
|
||||
type AuthInfo struct {
|
||||
// The client's claimed username on Twitch.
|
||||
TwitchUsername string
|
||||
|
||||
// Whether or not the server has validated the client's claimed username.
|
||||
UsernameValidated bool
|
||||
}
|
||||
|
||||
type ClientVersion struct {
|
||||
Major int
|
||||
Minor int
|
||||
Revision int
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
// The client ID.
|
||||
// This must be written once by the owning goroutine before the struct is passed off to any other goroutines.
|
||||
ClientID uuid.UUID
|
||||
|
||||
// The client's literal version string.
|
||||
// This must be written once by the owning goroutine before the struct is passed off to any other goroutines.
|
||||
VersionString string
|
||||
|
||||
Version ClientVersion
|
||||
|
||||
// This mutex protects writable data in this struct.
|
||||
// If it seems to be a performance problem, we can split this.
|
||||
Mutex sync.Mutex
|
||||
|
||||
// Info about the client's username and whether or not we have verified it.
|
||||
AuthInfo
|
||||
|
||||
RemoteAddr net.Addr
|
||||
|
||||
// Username validation nonce.
|
||||
ValidationNonce string
|
||||
|
||||
// The list of chats this client is currently in.
|
||||
// Protected by Mutex.
|
||||
CurrentChannels []string
|
||||
|
||||
// True if the client has already sent the 'ready' command
|
||||
ReadyComplete bool
|
||||
|
||||
// Server-initiated messages should be sent here
|
||||
// This field will be nil before it is closed.
|
||||
MessageChannel chan<- ClientMessage
|
||||
|
||||
MsgChannelIsDone <-chan struct{}
|
||||
|
||||
// Take out an Add() on this during a command if you need to use the MessageChannel later.
|
||||
MsgChannelKeepalive sync.WaitGroup
|
||||
|
||||
// The number of pings sent without a response.
|
||||
// Protected by Mutex
|
||||
pingCount int
|
||||
}
|
||||
|
||||
func VersionFromString(v string) ClientVersion {
|
||||
var cv ClientVersion
|
||||
fmt.Sscanf(v, "ffz_%d.%d.%d", &cv.Major, &cv.Minor, &cv.Revision)
|
||||
return cv
|
||||
}
|
||||
|
||||
func (cv *ClientVersion) After(cv2 *ClientVersion) bool {
|
||||
if cv.Major > cv2.Major {
|
||||
return true
|
||||
} else if cv.Major < cv2.Major {
|
||||
return false
|
||||
}
|
||||
if cv.Minor > cv2.Minor {
|
||||
return true
|
||||
} else if cv.Minor < cv2.Minor {
|
||||
return false
|
||||
}
|
||||
if cv.Revision > cv2.Revision {
|
||||
return true
|
||||
} else if cv.Revision < cv2.Revision {
|
||||
return false
|
||||
}
|
||||
|
||||
return false // equal
|
||||
}
|
||||
|
||||
func (cv *ClientVersion) Equal(cv2 *ClientVersion) bool {
|
||||
return cv.Major == cv2.Major && cv.Minor == cv2.Minor && cv.Revision == cv2.Revision
|
||||
}
|
244
socketserver/server/usercount.go
Normal file
244
socketserver/server/usercount.go
Normal file
|
@ -0,0 +1,244 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"io"
|
||||
|
||||
"github.com/clarkduvall/hyperloglog"
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// UuidHash implements a hash for uuid.UUID by XORing the random bits.
|
||||
type UuidHash uuid.UUID
|
||||
|
||||
func (u UuidHash) Sum64() uint64 {
|
||||
var valLow, valHigh uint64
|
||||
valLow = binary.LittleEndian.Uint64(u[0:8])
|
||||
valHigh = binary.LittleEndian.Uint64(u[8:16])
|
||||
return valLow ^ valHigh
|
||||
}
|
||||
|
||||
type PeriodUniqueUsers struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Counter *hyperloglog.HyperLogLogPlus
|
||||
}
|
||||
|
||||
type usageToken struct{}
|
||||
|
||||
const uniqCountDir = "./uniques"
|
||||
const UsersDailyFmt = "daily-%d-%d-%d.gob" // d-m-y
|
||||
const CounterPrecision uint8 = 12
|
||||
|
||||
var uniqueCounter PeriodUniqueUsers
|
||||
var uniqueUserChannel chan uuid.UUID
|
||||
var uniqueCtrWritingToken chan usageToken
|
||||
|
||||
var CounterLocation *time.Location = time.FixedZone("UTC-5", int((time.Hour*-5)/time.Second))
|
||||
|
||||
func TruncateToMidnight(at time.Time) time.Time {
|
||||
year, month, day := at.Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, CounterLocation)
|
||||
}
|
||||
|
||||
// GetCounterPeriod calculates the start and end timestamps for the HLL measurement period that includes the 'at' timestamp.
|
||||
func GetCounterPeriod(at time.Time) (start time.Time, end time.Time) {
|
||||
year, month, day := at.Date()
|
||||
start = time.Date(year, month, day, 0, 0, 0, 0, CounterLocation)
|
||||
end = time.Date(year, month, day+1, 0, 0, 0, 0, CounterLocation)
|
||||
return start, end
|
||||
}
|
||||
|
||||
// GetHLLFilename returns the filename for the saved HLL whose measurement period covers the given time.
|
||||
func GetHLLFilename(at time.Time) string {
|
||||
var filename string
|
||||
year, month, day := at.Date()
|
||||
filename = fmt.Sprintf(UsersDailyFmt, day, month, year)
|
||||
return fmt.Sprintf("%s/%s", uniqCountDir, filename)
|
||||
}
|
||||
|
||||
// loadHLL loads a HLL from disk and stores the result in dest.Counter.
|
||||
// If dest.Counter is nil, it will be initialized. (This is a useful side-effect.)
|
||||
// If dest is one of the uniqueCounters, the usageToken must be held.
|
||||
func loadHLL(at time.Time, dest *PeriodUniqueUsers) error {
|
||||
fileBytes, err := ioutil.ReadFile(GetHLLFilename(at))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dest.Counter == nil {
|
||||
dest.Counter, _ = hyperloglog.NewPlus(CounterPrecision)
|
||||
}
|
||||
|
||||
dec := gob.NewDecoder(bytes.NewReader(fileBytes))
|
||||
err = dec.Decode(dest.Counter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeHLL writes the indicated HLL to disk.
|
||||
// The function takes the usageToken.
|
||||
func writeHLL() error {
|
||||
token := <-uniqueCtrWritingToken
|
||||
result := writeHLL_do(&uniqueCounter)
|
||||
uniqueCtrWritingToken <- token
|
||||
return result
|
||||
}
|
||||
|
||||
// writeHLL_do writes out the HLL indicated by `which` to disk.
|
||||
// The usageToken must be held when calling this function.
|
||||
func writeHLL_do(hll *PeriodUniqueUsers) (err error) {
|
||||
filename := GetHLLFilename(hll.Start)
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func(file io.Closer) {
|
||||
fileErr := file.Close()
|
||||
if err == nil {
|
||||
err = fileErr
|
||||
}
|
||||
}(file)
|
||||
|
||||
enc := gob.NewEncoder(file)
|
||||
return enc.Encode(hll.Counter)
|
||||
}
|
||||
|
||||
// readCurrentHLL reads the current value of the active HLL counter.
|
||||
// The function takes the usageToken.
|
||||
func readCurrentHLL() uint64 {
|
||||
token := <-uniqueCtrWritingToken
|
||||
result := uniqueCounter.Counter.Count()
|
||||
uniqueCtrWritingToken <- token
|
||||
return result
|
||||
}
|
||||
|
||||
var hllFileServer = http.StripPrefix("/hll", http.FileServer(http.Dir(uniqCountDir)))
|
||||
|
||||
func HTTPShowHLL(w http.ResponseWriter, r *http.Request) {
|
||||
hllFileServer.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func HTTPWriteHLL(w http.ResponseWriter, r *http.Request) {
|
||||
writeHLL()
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// loadUniqueUsers loads the previous HLLs into memory.
|
||||
// is_init_func
|
||||
func loadUniqueUsers() {
|
||||
gob.RegisterName("hyperloglog", hyperloglog.HyperLogLogPlus{})
|
||||
err := os.MkdirAll(uniqCountDir, 0755)
|
||||
if err != nil {
|
||||
log.Panicln("could not make unique users data dir:", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(CounterLocation)
|
||||
uniqueCounter.Start, uniqueCounter.End = GetCounterPeriod(now)
|
||||
err = loadHLL(now, &uniqueCounter)
|
||||
isIgnorableError := err != nil && (false ||
|
||||
(os.IsNotExist(err)) ||
|
||||
(err == io.EOF))
|
||||
|
||||
if isIgnorableError {
|
||||
// file didn't finish writing
|
||||
// errors in NewPlus are bad precisions
|
||||
uniqueCounter.Counter, _ = hyperloglog.NewPlus(CounterPrecision)
|
||||
log.Println("failed to load unique users data:", err)
|
||||
} else if err != nil {
|
||||
log.Panicln("failed to load unique users data:", err)
|
||||
}
|
||||
|
||||
uniqueUserChannel = make(chan uuid.UUID)
|
||||
uniqueCtrWritingToken = make(chan usageToken)
|
||||
go processNewUsers()
|
||||
go rolloverCounters()
|
||||
uniqueCtrWritingToken <- usageToken{}
|
||||
}
|
||||
|
||||
// dumpUniqueUsers dumps all the data in uniqueCounters.
|
||||
func dumpUniqueUsers() {
|
||||
token := <-uniqueCtrWritingToken
|
||||
|
||||
uniqueCounter.Counter.Clear()
|
||||
|
||||
uniqueCtrWritingToken <- token
|
||||
}
|
||||
|
||||
// processNewUsers reads uniqueUserChannel, and also dispatches the writing token.
|
||||
// This function is the primary writer of uniqueCounters, so it makes sense for it to hold the token.
|
||||
// is_init_func
|
||||
func processNewUsers() {
|
||||
token := <-uniqueCtrWritingToken
|
||||
|
||||
for {
|
||||
select {
|
||||
case u := <-uniqueUserChannel:
|
||||
hashed := UuidHash(u)
|
||||
uniqueCounter.Counter.Add(hashed)
|
||||
case uniqueCtrWritingToken <- token:
|
||||
// relinquish token. important that there is only one of this going on
|
||||
// otherwise we thrash
|
||||
token = <-uniqueCtrWritingToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getNextMidnight() time.Time {
|
||||
now := time.Now().In(CounterLocation)
|
||||
year, month, day := now.Date()
|
||||
return time.Date(year, month, day+1, 0, 0, 1, 0, CounterLocation)
|
||||
}
|
||||
|
||||
// is_init_func
|
||||
func rolloverCounters() {
|
||||
for {
|
||||
duration := getNextMidnight().Sub(time.Now())
|
||||
// fmt.Println(duration)
|
||||
time.Sleep(duration)
|
||||
rolloverCounters_do()
|
||||
}
|
||||
}
|
||||
|
||||
func rolloverCounters_do() {
|
||||
var token usageToken
|
||||
var now time.Time
|
||||
|
||||
token = <-uniqueCtrWritingToken
|
||||
now = time.Now().In(CounterLocation)
|
||||
// Cycle for period
|
||||
err := writeHLL_do(&uniqueCounter)
|
||||
if err != nil {
|
||||
log.Println("could not cycle unique user counter:", err)
|
||||
|
||||
// Attempt to rescue the data into the log
|
||||
var buf bytes.Buffer
|
||||
bytes, err := uniqueCounter.Counter.GobEncode()
|
||||
if err == nil {
|
||||
enc := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
enc.Write(bytes)
|
||||
enc.Close()
|
||||
log.Print("data for ", GetHLLFilename(uniqueCounter.Start), ":", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
uniqueCounter.Start, uniqueCounter.End = GetCounterPeriod(now)
|
||||
// errors are bad precisions, so we can ignore
|
||||
uniqueCounter.Counter, _ = hyperloglog.NewPlus(CounterPrecision)
|
||||
|
||||
uniqueCtrWritingToken <- token
|
||||
}
|
68
socketserver/server/usercount_test.go
Normal file
68
socketserver/server/usercount_test.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
func TestUniqueConnections(t *testing.T) {
|
||||
const TestExpectedCount = 1000
|
||||
|
||||
testStart := time.Now().In(CounterLocation)
|
||||
|
||||
var server *httptest.Server
|
||||
var backendExpected = NewTBackendRequestChecker(t,
|
||||
TExpectedBackendRequest{200, bPathAnnounceStartup, &url.Values{"startup": []string{"1"}}, "", nil},
|
||||
)
|
||||
server, _, _ = TSetup(SetupWantSocketServer|SetupWantBackendServer, backendExpected)
|
||||
|
||||
defer server.CloseClientConnections()
|
||||
defer unsubscribeAllClients()
|
||||
defer backendExpected.Close()
|
||||
|
||||
dumpUniqueUsers()
|
||||
|
||||
for i := 0; i < TestExpectedCount; i++ {
|
||||
uuid := uuid.NewV4()
|
||||
uniqueUserChannel <- uuid
|
||||
uniqueUserChannel <- uuid
|
||||
}
|
||||
|
||||
TCheckHLLValue(t, TestExpectedCount, readCurrentHLL())
|
||||
|
||||
token := <-uniqueCtrWritingToken
|
||||
uniqueCounter.End = time.Now().In(CounterLocation).Add(-1 * time.Second)
|
||||
uniqueCtrWritingToken <- token
|
||||
|
||||
rolloverCounters_do()
|
||||
|
||||
for i := 0; i < TestExpectedCount; i++ {
|
||||
uuid := uuid.NewV4()
|
||||
uniqueUserChannel <- uuid
|
||||
uniqueUserChannel <- uuid
|
||||
}
|
||||
|
||||
TCheckHLLValue(t, TestExpectedCount, readCurrentHLL())
|
||||
|
||||
// Check: Merging the two days results in 2000
|
||||
// note: rolloverCounters_do() wrote out a file, and loadHLL() is reading it back
|
||||
// TODO need to rewrite some of the test to make this work
|
||||
var loadDest PeriodUniqueUsers
|
||||
loadHLL(testStart, &loadDest)
|
||||
|
||||
token = <-uniqueCtrWritingToken
|
||||
loadDest.Counter.Merge(uniqueCounter.Counter)
|
||||
uniqueCtrWritingToken <- token
|
||||
|
||||
TCheckHLLValue(t, TestExpectedCount*2, loadDest.Counter.Count())
|
||||
}
|
||||
|
||||
func TestUniqueUsersCleanup(t *testing.T) {
|
||||
// Not a test. Removes old files.
|
||||
os.RemoveAll(uniqCountDir)
|
||||
}
|
|
@ -5,11 +5,11 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
func FillCryptoRandom(buf []byte) error {
|
||||
|
@ -24,11 +24,11 @@ func FillCryptoRandom(buf []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func New4KByteBuffer() interface{} {
|
||||
return make([]byte, 0, 4096)
|
||||
func copyString(s string) string {
|
||||
return string([]byte(s))
|
||||
}
|
||||
|
||||
func SealRequest(form url.Values) (url.Values, error) {
|
||||
func (backend *backendInfo) SealRequest(form url.Values) (url.Values, error) {
|
||||
var nonce [24]byte
|
||||
var err error
|
||||
|
||||
|
@ -37,7 +37,7 @@ func SealRequest(form url.Values) (url.Values, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
cipherMsg := box.SealAfterPrecomputation(nil, []byte(form.Encode()), &nonce, &backendSharedKey)
|
||||
cipherMsg := box.SealAfterPrecomputation(nil, []byte(form.Encode()), &nonce, &backend.sharedKey)
|
||||
|
||||
bufMessage := new(bytes.Buffer)
|
||||
enc := base64.NewEncoder(base64.URLEncoding, bufMessage)
|
||||
|
@ -54,7 +54,7 @@ func SealRequest(form url.Values) (url.Values, error) {
|
|||
retval := url.Values{
|
||||
"nonce": []string{nonceString},
|
||||
"msg": []string{cipherString},
|
||||
"id": []string{strconv.Itoa(serverId)},
|
||||
"id": []string{strconv.Itoa(Backend.serverID)},
|
||||
}
|
||||
|
||||
return retval, nil
|
||||
|
@ -63,16 +63,18 @@ func SealRequest(form url.Values) (url.Values, error) {
|
|||
var ErrorShortNonce = errors.New("Nonce too short.")
|
||||
var ErrorInvalidSignature = errors.New("Invalid signature or contents")
|
||||
|
||||
func UnsealRequest(form url.Values) (url.Values, error) {
|
||||
func (backend *backendInfo) UnsealRequest(form url.Values) (url.Values, error) {
|
||||
var nonce [24]byte
|
||||
|
||||
nonceString := form.Get("nonce")
|
||||
dec := base64.NewDecoder(base64.URLEncoding, strings.NewReader(nonceString))
|
||||
count, err := dec.Read(nonce[:])
|
||||
if err != nil {
|
||||
Statistics.BackendVerifyFails++
|
||||
return nil, err
|
||||
}
|
||||
if count != 24 {
|
||||
Statistics.BackendVerifyFails++
|
||||
return nil, ErrorShortNonce
|
||||
}
|
||||
|
||||
|
@ -81,15 +83,15 @@ func UnsealRequest(form url.Values) (url.Values, error) {
|
|||
cipherBuffer := new(bytes.Buffer)
|
||||
cipherBuffer.ReadFrom(dec)
|
||||
|
||||
message, ok := box.OpenAfterPrecomputation(nil, cipherBuffer.Bytes(), &nonce, &backendSharedKey)
|
||||
message, ok := box.OpenAfterPrecomputation(nil, cipherBuffer.Bytes(), &nonce, &backend.sharedKey)
|
||||
if !ok {
|
||||
Statistics.BackendVerifyFails++
|
||||
return nil, ErrorInvalidSignature
|
||||
}
|
||||
|
||||
retValues, err := url.ParseQuery(string(message))
|
||||
if err != nil {
|
||||
// Assume that the signature was accidentally correct but the contents were garbage
|
||||
log.Print(err)
|
||||
Statistics.BackendVerifyFails++
|
||||
return nil, ErrorInvalidSignature
|
||||
}
|
||||
|
||||
|
@ -159,3 +161,49 @@ func RemoveFromSliceC(ary *[]chan<- ClientMessage, val chan<- ClientMessage) boo
|
|||
*ary = slice
|
||||
return true
|
||||
}
|
||||
|
||||
func AddToSliceCl(ary *[]*ClientInfo, val *ClientInfo) bool {
|
||||
slice := *ary
|
||||
for _, v := range slice {
|
||||
if v == val {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
slice = append(slice, val)
|
||||
*ary = slice
|
||||
return true
|
||||
}
|
||||
|
||||
func RemoveFromSliceCl(ary *[]*ClientInfo, val *ClientInfo) bool {
|
||||
slice := *ary
|
||||
var idx int = -1
|
||||
for i, v := range slice {
|
||||
if v == val {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
slice[idx] = slice[len(slice)-1]
|
||||
slice = slice[:len(slice)-1]
|
||||
*ary = slice
|
||||
return true
|
||||
}
|
||||
|
||||
func AddToSliceB(ary *[]bunchSubscriber, client *ClientInfo, mid int) bool {
|
||||
newSub := bunchSubscriber{Client: client, MessageID: mid}
|
||||
slice := *ary
|
||||
for _, v := range slice {
|
||||
if v == newSub {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
slice = append(slice, newSub)
|
||||
*ary = slice
|
||||
return true
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue