1
0
Fork 0
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:
SirStendec 2016-10-01 14:34:31 -04:00
commit 886f45b990
42 changed files with 6135 additions and 1959 deletions

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,3 @@
config.json
ffzsocketserver
uniques/

View 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()
}

View 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
&mdash; CatBag by <a href="http://www.twitch.tv/wolsk">Wolsk</a>
</div>
</div>

View file

@ -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 ( import (
"../../internal/server"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
@ -9,16 +8,23 @@ import (
"log" "log"
"net/http" "net/http"
"os" "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.") import _ "net/http/pprof"
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.")
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() { func main() {
flag.Parse() flag.Parse()
if *generateKeys { if *flagGenerateKeys {
GenerateKeys(*configFilename) generateKeys(*configFilename)
return return
} }
@ -40,24 +46,30 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
httpServer := &http.Server{ // logFile, err := os.OpenFile("output.log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
Addr: conf.ListenAddr, // 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 { if conf.UseSSL {
err = httpServer.ListenAndServeTLS(conf.SSLCertificateFile, conf.SSLKeyFile) go func() {
} else { if err := http.ListenAndServeTLS(conf.SSLListenAddr, conf.SSLCertificateFile, conf.SSLKeyFile, http.DefaultServeMux); err != nil {
err = httpServer.ListenAndServe() log.Fatal("ListenAndServeTLS: ", err)
}
}()
} }
if err != nil { if err = http.ListenAndServe(conf.ListenAddr, http.DefaultServeMux); err != nil {
log.Fatal("ListenAndServe: ", err) log.Fatal("ListenAndServe: ", err)
} }
} }
func GenerateKeys(outputFile string) { func generateKeys(outputFile string) {
if flag.NArg() < 1 { if flag.NArg() < 1 {
fmt.Println("Specify a numeric server ID after -genkeys") fmt.Println("Specify a numeric server ID after -genkeys")
os.Exit(2) os.Exit(2)

View file

@ -0,0 +1 @@
mergecounts

View 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
View file

@ -0,0 +1,3 @@
database.sqlite
gobcache/
statsweb

View 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
}

View file

@ -0,0 +1 @@
{"ListenAddr":"localhost:3000","DatabaseLocation":"./database.sqlite","GobFilesLocation":"./gobcache"}

View 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
}

View 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
}

View 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}
}

View 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>

View 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>

View 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>

View file

@ -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
&mdash; 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)
}
}

View file

@ -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)
}
}

View file

@ -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)))
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}

View 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)
}
}

View 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: .*")
}

View 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()
}

View 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
}
}

View file

@ -2,14 +2,15 @@ package server
import ( import (
"fmt" "fmt"
"golang.org/x/net/websocket"
"testing" "testing"
"github.com/gorilla/websocket"
) )
func ExampleUnmarshalClientMessage() { func ExampleUnmarshalClientMessage() {
sourceData := []byte("100 hello [\"ffz_3.5.30\",\"898b5bfa-b577-47bb-afb4-252c703b67d6\"]") sourceData := []byte("100 hello [\"ffz_3.5.30\",\"898b5bfa-b577-47bb-afb4-252c703b67d6\"]")
var cm ClientMessage var cm ClientMessage
err := UnmarshalClientMessage(sourceData, websocket.TextFrame, &cm) err := UnmarshalClientMessage(sourceData, websocket.TextMessage, &cm)
fmt.Println(err) fmt.Println(err)
fmt.Println(cm.MessageID) fmt.Println(cm.MessageID)
fmt.Println(cm.Command) fmt.Println(cm.Command)
@ -27,9 +28,9 @@ func ExampleMarshalClientMessage() {
Command: "do_authorize", Command: "do_authorize",
Arguments: "1234567890", Arguments: "1234567890",
} }
data, payloadType, err := MarshalClientMessage(&cm) payloadType, data, err := MarshalClientMessage(&cm)
fmt.Println(err) fmt.Println(err)
fmt.Println(payloadType == websocket.TextFrame) fmt.Println(payloadType == websocket.TextMessage)
fmt.Println(string(data)) fmt.Println(string(data))
// Output: // Output:
// <nil> // <nil>
@ -40,7 +41,7 @@ func ExampleMarshalClientMessage() {
func TestArgumentsAsStringAndBool(t *testing.T) { func TestArgumentsAsStringAndBool(t *testing.T) {
sourceData := []byte("1 foo [\"string\", false]") sourceData := []byte("1 foo [\"string\", false]")
var cm ClientMessage var cm ClientMessage
err := UnmarshalClientMessage(sourceData, websocket.TextFrame, &cm) err := UnmarshalClientMessage(sourceData, websocket.TextMessage, &cm)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View 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
View 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)
}
}

View 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()
}
}
}

View 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, ",")))
}

View 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")
}
}

View 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)
}

View file

@ -4,6 +4,7 @@ package server
// If I screwed up the locking, I won't know until it's too late. // If I screwed up the locking, I won't know until it's too late.
import ( import (
"log"
"sync" "sync"
"time" "time"
) )
@ -15,9 +16,43 @@ type SubscriberList struct {
var ChatSubscriptionInfo map[string]*SubscriberList = make(map[string]*SubscriberList) var ChatSubscriptionInfo map[string]*SubscriberList = make(map[string]*SubscriberList)
var ChatSubscriptionLock sync.RWMutex 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() ChatSubscriptionLock.RLock()
list := ChatSubscriptionInfo[channel] list := ChatSubscriptionInfo[channel]
if list != nil { if list != nil {
@ -58,15 +93,99 @@ func PublishToMultiple(channels []string, msg ClientMessage) (count int) {
} }
func PublishToAll(msg ClientMessage) (count int) { func PublishToAll(msg ClientMessage) (count int) {
GlobalSubscriptionInfo.RLock() GlobalSubscriptionLock.RLock()
for _, msgChan := range GlobalSubscriptionInfo.Members { for _, client := range GlobalSubscriptionInfo {
msgChan <- msg select {
case client.MessageChannel <- msg:
case <-client.MsgChannelIsDone:
}
count++ count++
} }
GlobalSubscriptionInfo.RUnlock() GlobalSubscriptionLock.RUnlock()
return 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. // Add a channel to the subscriptions while holding a read-lock to the map.
// Locks: // Locks:
// - ALREADY HOLDING a read-lock to the 'which' top-level map via the rlocker object // - 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 list.Members = []chan<- ClientMessage{value} // Create it populated, to avoid reaper
ChatSubscriptionInfo[channelName] = list ChatSubscriptionInfo[channelName] = list
ChatSubscriptionLock.Unlock() ChatSubscriptionLock.Unlock()
go func(topic string) {
err := Backend.SendNewTopicNotice(topic)
if err != nil {
log.Println("error reporting new sub:", err)
}
}(channelName)
ChatSubscriptionLock.RLock() ChatSubscriptionLock.RLock()
} else { } else {
list.Lock() list.Lock()
@ -89,80 +216,3 @@ func _subscribeWhileRlocked(channelName string, value chan<- ClientMessage) {
list.Unlock() 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()
}
}

View file

@ -1,172 +1,21 @@
package server package server
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/satori/go.uuid"
"golang.org/x/net/websocket"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strconv" "strconv"
"sync" "sync"
"syscall" "syscall"
"testing" "testing"
"time" "time"
"github.com/gorilla/websocket"
"github.com/satori/go.uuid"
) )
func TCountOpenFDs() uint64 { const TestOrigin = "http://www.twitch.tv"
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
&mdash; 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)
}
}
}
func TestSubscriptionAndPublish(t *testing.T) { func TestSubscriptionAndPublish(t *testing.T) {
var doneWg sync.WaitGroup var doneWg sync.WaitGroup
@ -184,19 +33,30 @@ func TestSubscriptionAndPublish(t *testing.T) {
const TestData3 = false const TestData3 = false
var TestData4 = []interface{}{"str1", "str2", "str3"} var TestData4 = []interface{}{"str1", "str2", "str3"}
ServerInitiatedCommands[TestCommandChan] = PushCommandCacheInfo{CacheTypeLastOnly, MsgTargetTypeChat} t.Log("TestSubscriptionAndPublish")
ServerInitiatedCommands[TestCommandMulti] = PushCommandCacheInfo{CacheTypeTimestamps, MsgTargetTypeMultichat}
ServerInitiatedCommands[TestCommandGlobal] = PushCommandCacheInfo{CacheTypeTimestamps, MsgTargetTypeGlobal}
var server *httptest.Server var server *httptest.Server
var urls TURLs 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 server.CloseClientConnections()
defer unsubscribeAllClients() defer unsubscribeAllClients()
defer backendExpected.Close()
var conn *websocket.Conn var conn *websocket.Conn
var resp *http.Response
var err error var err error
var headers http.Header = make(http.Header)
headers.Set("Origin", TestOrigin)
// client 1: sub ch1, ch2 // client 1: sub ch1, ch2
// client 2: sub ch1, ch3 // client 2: sub ch1, ch3
// client 3: sub none // client 3: sub none
@ -204,15 +64,18 @@ func TestSubscriptionAndPublish(t *testing.T) {
// msg 1: ch1 // msg 1: ch1
// msg 2: ch2, ch3 // msg 2: ch2, ch3
// msg 3: chEmpty // msg 3: chEmpty
// msg 4: global // msg 4: global uncached
// Client 1 // Client 1
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin) conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
} }
// both origins need testing
headers.Set("Origin", "https://www.twitch.tv")
doneWg.Add(1) doneWg.Add(1)
readyWg.Add(1) readyWg.Add(1)
go func(conn *websocket.Conn) { go func(conn *websocket.Conn) {
@ -236,13 +99,14 @@ func TestSubscriptionAndPublish(t *testing.T) {
}(conn) }(conn)
// Client 2 // Client 2
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin) conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
} }
doneWg.Add(1) doneWg.Add(1)
readyWg.Wait() // enforce ordering
readyWg.Add(1) readyWg.Add(1)
go func(conn *websocket.Conn) { go func(conn *websocket.Conn) {
TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()}) TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()})
@ -265,13 +129,15 @@ func TestSubscriptionAndPublish(t *testing.T) {
}(conn) }(conn)
// Client 3 // Client 3
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin) conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
} }
doneWg.Add(1) doneWg.Add(1)
readyWg.Wait() // enforce ordering
time.Sleep(2 * time.Millisecond)
readyWg.Add(1) readyWg.Add(1)
go func(conn *websocket.Conn) { go func(conn *websocket.Conn) {
TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()}) TSendMessage(t, conn, 1, HelloCommand, []interface{}{"ffz_0.0-test", uuid.NewV4().String()})
@ -291,7 +157,6 @@ func TestSubscriptionAndPublish(t *testing.T) {
readyWg.Wait() readyWg.Wait()
var form url.Values var form url.Values
var resp *http.Response
// Publish message 1 - should go to clients 1, 2 // Publish message 1 - should go to clients 1, 2
@ -300,7 +165,7 @@ func TestSubscriptionAndPublish(t *testing.T) {
t.FailNow() t.FailNow()
} }
resp, err = http.PostForm(urls.SavePubMsg, form) 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() t.FailNow()
} }
@ -311,7 +176,7 @@ func TestSubscriptionAndPublish(t *testing.T) {
t.FailNow() t.FailNow()
} }
resp, err = http.PostForm(urls.SavePubMsg, form) 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() t.FailNow()
} }
@ -322,23 +187,23 @@ func TestSubscriptionAndPublish(t *testing.T) {
t.FailNow() t.FailNow()
} }
resp, err = http.PostForm(urls.SavePubMsg, form) 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() t.FailNow()
} }
// Publish message 4 - should go to clients 1, 2, 3 // 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 { if err != nil {
t.FailNow() t.FailNow()
} }
resp, err = http.PostForm(urls.SavePubMsg, form) resp, err = http.PostForm(urls.UncachedPubMsg, form)
if !TCheckResponse(t, resp, strconv.Itoa(3)) { if !TCheckResponse(t, resp, strconv.Itoa(3), "pub msg 4") {
t.FailNow() t.FailNow()
} }
// Start client 4 // Start client 4
conn, err = websocket.Dial(urls.Websocket, "", urls.Origin) conn, resp, err = websocket.DefaultDialer.Dial(urls.Websocket, headers)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
@ -367,6 +232,113 @@ func TestSubscriptionAndPublish(t *testing.T) {
doneWg.Wait() doneWg.Wait()
server.Close() 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) { func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
@ -396,12 +368,15 @@ func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
var server *httptest.Server var server *httptest.Server
var urls TURLs var urls TURLs
TSetup(&server, &urls) server, _, urls = TSetup(SetupWantSocketServer|SetupWantURLs, nil)
defer unsubscribeAllClients() defer unsubscribeAllClients()
var headers http.Header = make(http.Header)
headers.Set("Origin", TestOrigin)
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { 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 { if err != nil {
b.Error(err) b.Error(err)
break break
@ -427,7 +402,7 @@ func BenchmarkUserSubscriptionSinglePublish(b *testing.B) {
readyWg.Wait() readyWg.Wait()
fmt.Println("publishing...") fmt.Println("publishing...")
if PublishToChat(TestChannelName, message) != b.N { if PublishToChannel(TestChannelName, message) != b.N {
b.Error("not enough sent") b.Error("not enough sent")
server.CloseClientConnections() server.CloseClientConnections()
panic("halting test instead of waiting") panic("halting test instead of waiting")

View 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
&mdash; 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)
}
}

View 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

View 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
}

View 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
}

View 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)
}

View file

@ -5,11 +5,11 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors" "errors"
"golang.org/x/crypto/nacl/box"
"log"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"golang.org/x/crypto/nacl/box"
) )
func FillCryptoRandom(buf []byte) error { func FillCryptoRandom(buf []byte) error {
@ -24,11 +24,11 @@ func FillCryptoRandom(buf []byte) error {
return nil return nil
} }
func New4KByteBuffer() interface{} { func copyString(s string) string {
return make([]byte, 0, 4096) 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 nonce [24]byte
var err error var err error
@ -37,7 +37,7 @@ func SealRequest(form url.Values) (url.Values, error) {
return nil, err 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) bufMessage := new(bytes.Buffer)
enc := base64.NewEncoder(base64.URLEncoding, bufMessage) enc := base64.NewEncoder(base64.URLEncoding, bufMessage)
@ -54,7 +54,7 @@ func SealRequest(form url.Values) (url.Values, error) {
retval := url.Values{ retval := url.Values{
"nonce": []string{nonceString}, "nonce": []string{nonceString},
"msg": []string{cipherString}, "msg": []string{cipherString},
"id": []string{strconv.Itoa(serverId)}, "id": []string{strconv.Itoa(Backend.serverID)},
} }
return retval, nil return retval, nil
@ -63,16 +63,18 @@ func SealRequest(form url.Values) (url.Values, error) {
var ErrorShortNonce = errors.New("Nonce too short.") var ErrorShortNonce = errors.New("Nonce too short.")
var ErrorInvalidSignature = errors.New("Invalid signature or contents") 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 var nonce [24]byte
nonceString := form.Get("nonce") nonceString := form.Get("nonce")
dec := base64.NewDecoder(base64.URLEncoding, strings.NewReader(nonceString)) dec := base64.NewDecoder(base64.URLEncoding, strings.NewReader(nonceString))
count, err := dec.Read(nonce[:]) count, err := dec.Read(nonce[:])
if err != nil { if err != nil {
Statistics.BackendVerifyFails++
return nil, err return nil, err
} }
if count != 24 { if count != 24 {
Statistics.BackendVerifyFails++
return nil, ErrorShortNonce return nil, ErrorShortNonce
} }
@ -81,15 +83,15 @@ func UnsealRequest(form url.Values) (url.Values, error) {
cipherBuffer := new(bytes.Buffer) cipherBuffer := new(bytes.Buffer)
cipherBuffer.ReadFrom(dec) cipherBuffer.ReadFrom(dec)
message, ok := box.OpenAfterPrecomputation(nil, cipherBuffer.Bytes(), &nonce, &backendSharedKey) message, ok := box.OpenAfterPrecomputation(nil, cipherBuffer.Bytes(), &nonce, &backend.sharedKey)
if !ok { if !ok {
Statistics.BackendVerifyFails++
return nil, ErrorInvalidSignature return nil, ErrorInvalidSignature
} }
retValues, err := url.ParseQuery(string(message)) retValues, err := url.ParseQuery(string(message))
if err != nil { if err != nil {
// Assume that the signature was accidentally correct but the contents were garbage Statistics.BackendVerifyFails++
log.Print(err)
return nil, ErrorInvalidSignature return nil, ErrorInvalidSignature
} }
@ -159,3 +161,49 @@ func RemoveFromSliceC(ary *[]chan<- ClientMessage, val chan<- ClientMessage) boo
*ary = slice *ary = slice
return true 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
}