diff --git a/config.go b/config.go new file mode 100644 index 0000000..b0c711e --- /dev/null +++ b/config.go @@ -0,0 +1,119 @@ +package main + +import ( + "errors" + "log" + "math" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + DEFAULT_LISTENER_PORT uint16 = 43383 + DEFAULT_STATS_FILENAME string = "./stats.json" + DEFAULT_HEARTBEAT_INTERVAL time.Duration = 30 * time.Second + DEFAULT_INACTIVITY_INTERVAL time.Duration = 5 * time.Minute + + ENVVAR_PREFIX string = "ANCHOR" + + LISTENER_ADDRESS_ENVVAR string = "LISTENER_ADDRESS" + LISTENER_PORT_ENVVAR string = "LISTENER_PORT" + STATS_FILENAME_ENVVAR string = "STATS_FILENAME" + HEARTBEAT_INTERVAL_S_ENVVAR string = "HEARTBEAT_INTERVAL_S" + INACTIVITY_INTERVAL_S_ENVVAR string = "INACTIVITY_INTERVAL_S" +) + +type Configuration struct { + ListenerAddress net.IP + ListenerPort uint16 + StatsFilename string + HeartbeatInterval time.Duration + InactivityInterval time.Duration +} + +func passthroughString(value string) (string, error) { + return value, nil +} + +func toIP(value string) (net.IP, error) { + return net.ParseIP(value), nil +} + +func toUint16(value string) (uint16, error) { + intValue, err := strconv.ParseUint(value, 10, 16) + + if err != nil { + return 0, err + } + + if intValue > math.MaxUint16 { + return 0, errors.New("value is greater than 65355") + } + + return uint16(intValue), nil +} + +func toDurationSeconds(value string) (time.Duration, error) { + seconds, err := strconv.ParseUint(value, 10, 64) + + if err != nil { + return 0, err + } + + return time.Duration(seconds) * time.Second, nil +} + +func resolveConfigurationValue[T any](envvarKey string, envvarValueAdapter func(string) (T, error), fallbackValue T) T { + fullEnvvarKey := strings.Join([]string{ENVVAR_PREFIX, envvarKey}, "_") + envvarValue, isSet := os.LookupEnv(fullEnvvarKey) + + if isSet { + value, err := envvarValueAdapter(envvarValue) + + if err == nil { + return value + } + + log.Printf("Falling back to default value due to failed parse of value for the environment variable '%s': %e", fullEnvvarKey, err) + } + + return fallbackValue +} + +func NewConfiguration() (*Configuration, error) { + statsFilename, err := filepath.Abs( + resolveConfigurationValue(STATS_FILENAME_ENVVAR, passthroughString, DEFAULT_STATS_FILENAME), + ) + + if err != nil { + return nil, err + } + + return &Configuration{ + ListenerAddress: resolveConfigurationValue(LISTENER_ADDRESS_ENVVAR, toIP, net.IPv6unspecified), // Listen on all IPv4 and IPv6 by default + ListenerPort: resolveConfigurationValue(LISTENER_PORT_ENVVAR, toUint16, DEFAULT_LISTENER_PORT), + StatsFilename: statsFilename, + HeartbeatInterval: resolveConfigurationValue(HEARTBEAT_INTERVAL_S_ENVVAR, toDurationSeconds, DEFAULT_HEARTBEAT_INTERVAL), + InactivityInterval: resolveConfigurationValue(INACTIVITY_INTERVAL_S_ENVVAR, toDurationSeconds, DEFAULT_INACTIVITY_INTERVAL), + }, nil +} + +func (c *Configuration) Print() { + log.Println("Server configuration:") + log.Printf(" - Listener Address: %v", c.ListenerAddress) + log.Printf(" - Listener Port: %d", c.ListenerPort) + log.Printf(" - Stats Filename: %s", c.StatsFilename) + log.Printf(" - Heartbeat Interval: %v", c.HeartbeatInterval) + log.Printf(" - Inactivity Interval: %v", c.InactivityInterval) +} + +func (c *Configuration) NewTCPAddress() *net.TCPAddr { + return &net.TCPAddr{ + IP: c.ListenerAddress, + Port: int(c.ListenerPort), + } +} diff --git a/go.mod b/go.mod index 74abb52..0fd25a3 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module garrettjoecox/anchor go 1.21.0 require ( - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.18.0 github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect + github.com/tidwall/sjson v1.2.5 ) diff --git a/main.go b/main.go index ba59735..068ca4f 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,15 @@ import ( ) func main() { - server := NewServer() + PrintRuntime() + config, err := NewConfiguration() + + if err != nil { + log.Fatalf("Failed configuration discovery: %e", err) + } + + config.Print() + server := NewServer(config) errChan := make(chan error) sigsCa := make(chan os.Signal, 1) @@ -45,6 +53,10 @@ func main() { server.Start(errChan) } +func PrintRuntime() { + log.Printf("Anchor server running on %s/%s, built using %v", runtime.GOOS, runtime.GOARCH, runtime.Version()) +} + func getMessage(input []string) string { var message bytes.Buffer diff --git a/server.go b/server.go index 09e622a..773d615 100644 --- a/server.go +++ b/server.go @@ -18,10 +18,9 @@ import ( ) const JSON_TEMPLATE = `{"gameCompleteCount":0,"onlineCount":0,"lastStatsHeartbeat":"","uniqueCount":0,"pid":0}` -const INACTIVITY_TIMEOUT = 5 * time.Minute -const HEARTBEAT = 30 * time.Second type Server struct { + configuration *Configuration listener net.Listener quietMode atomic.Bool onlineClients sync.Map @@ -30,8 +29,9 @@ type Server struct { nextClientId atomic.Uint64 } -func NewServer() *Server { +func NewServer(configuration *Configuration) *Server { return &Server{ + configuration: configuration, onlineClients: sync.Map{}, quietMode: atomic.Bool{}, rooms: sync.Map{}, @@ -40,22 +40,26 @@ func NewServer() *Server { } } -func (s *Server) Start(errChan chan error) { - listener, err := net.Listen("tcp", ":43383") +func (s *Server) openListener() { + listener, err := net.ListenTCP("tcp", s.configuration.NewTCPAddress()) if err != nil { log.Fatal(err) } s.listener = listener +} + +func (s *Server) Start(errChan chan error) { + s.openListener() go s.cleanupInactiveRooms(errChan) go s.heartbeat(errChan) go s.parseStats(errChan) go s.statsHeartbeat(errChan) - log.Println("Server running on :43383") + log.Printf("Server listening on %v", s.listener.Addr()) for { - conn, err := listener.Accept() + conn, err := s.listener.Accept() if err != nil { if errors.Is(err, net.ErrClosed) { log.Println("Error with listener:", err) @@ -114,7 +118,7 @@ func (s *Server) saveStats() { } func (s *Server) cleanupInactiveRooms(errChan chan error) { - ticker := time.NewTicker(HEARTBEAT) + ticker := time.NewTicker(s.configuration.HeartbeatInterval) defer ticker.Stop() defer func() { if r := recover(); r != nil { @@ -126,7 +130,7 @@ func (s *Server) cleanupInactiveRooms(errChan chan error) { s.rooms.Range(func(id, value interface{}) bool { room := value.(*Room) lastActivity := room.GetLastActivity() - if time.Since(lastActivity) > INACTIVITY_TIMEOUT { + if time.Since(lastActivity) > s.configuration.InactivityInterval { log.Println("Room", id, "has been inactive for too long, deleting it") s.rooms.Delete(id) } @@ -136,7 +140,7 @@ func (s *Server) cleanupInactiveRooms(errChan chan error) { } func (s *Server) statsHeartbeat(errChan chan error) { - ticker := time.NewTicker(HEARTBEAT) + ticker := time.NewTicker(s.configuration.HeartbeatInterval) defer ticker.Stop() defer func() { if r := recover(); r != nil { @@ -150,7 +154,7 @@ func (s *Server) statsHeartbeat(errChan chan error) { } func (s *Server) heartbeat(errChan chan error) { - ticker := time.NewTicker(HEARTBEAT) + ticker := time.NewTicker(s.configuration.HeartbeatInterval) defer ticker.Stop() defer func() { if r := recover(); r != nil { @@ -165,7 +169,7 @@ func (s *Server) heartbeat(errChan chan error) { s.onlineClients.Range(func(_, value interface{}) bool { client := value.(*Client) - if time.Since(client.lastActivity) > HEARTBEAT { + if time.Since(client.lastActivity) > s.configuration.HeartbeatInterval { go client.sendPacket(`{"type":"HEARTBEAT","quiet":true}`) } return true